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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE-AGPLv3.txt +661 -0
  3. data/README.md +91 -0
  4. data/Rakefile +42 -0
  5. data/app/cells/decidim/stratified_sortitions/stratified_sortition_cell.rb +21 -0
  6. data/app/cells/decidim/stratified_sortitions/stratified_sortition_l_cell.rb +22 -0
  7. data/app/cells/decidim/stratified_sortitions/stratified_sortition_metadata_cell.rb +63 -0
  8. data/app/commands/decidim/stratified_sortitions/admin/create_stratified_sortition.rb +84 -0
  9. data/app/commands/decidim/stratified_sortitions/admin/destroy_stratified_sortition.rb +49 -0
  10. data/app/commands/decidim/stratified_sortitions/admin/duplicate_stratified_sortition.rb +74 -0
  11. data/app/commands/decidim/stratified_sortitions/admin/import_sample.rb +40 -0
  12. data/app/commands/decidim/stratified_sortitions/admin/remove_uploaded_samples.rb +53 -0
  13. data/app/commands/decidim/stratified_sortitions/admin/update_stratified_sortition.rb +174 -0
  14. data/app/controllers/concerns/decidim/stratified_sortitions/orderable_stratified_sortitions.rb +37 -0
  15. data/app/controllers/concerns/decidim/stratified_sortitions/strata_charts_data.rb +91 -0
  16. data/app/controllers/decidim/stratified_sortitions/admin/application_controller.rb +19 -0
  17. data/app/controllers/decidim/stratified_sortitions/admin/samples_controller.rb +143 -0
  18. data/app/controllers/decidim/stratified_sortitions/admin/stratified_sortitions_controller.rb +235 -0
  19. data/app/controllers/decidim/stratified_sortitions/application_controller.rb +13 -0
  20. data/app/controllers/decidim/stratified_sortitions/charts_pdf_controller_helper.rb +17 -0
  21. data/app/controllers/decidim/stratified_sortitions/stratified_sortitions_controller.rb +61 -0
  22. data/app/forms/decidim/stratified_sortitions/admin/sample_upload_form.rb +15 -0
  23. data/app/forms/decidim/stratified_sortitions/admin/stratified_sortitions_form.rb +155 -0
  24. data/app/forms/decidim/stratified_sortitions/admin/stratum_form.rb +69 -0
  25. data/app/forms/decidim/stratified_sortitions/admin/substratum_form.rb +26 -0
  26. data/app/helpers/decidim/stratified_sortitions/admin/stratified_sortitions_helper.rb +18 -0
  27. data/app/helpers/decidim/stratified_sortitions/application_helper.rb +85 -0
  28. data/app/helpers/decidim/stratified_sortitions/charts_pdf_helper.rb +73 -0
  29. data/app/helpers/decidim/stratified_sortitions/stratified_sortition_cells_helper.rb +56 -0
  30. data/app/javascript/channels/sample_import_progress.js +12 -0
  31. data/app/jobs/decidim/stratified_sortitions/admin/execute_sortition_job.rb +21 -0
  32. data/app/jobs/decidim/stratified_sortitions/admin/import_sample_job.rb +170 -0
  33. data/app/jobs/decidim/stratified_sortitions/admin/remove_samples_job.rb +19 -0
  34. data/app/jobs/decidim/stratified_sortitions/admin/sortition_results_export_job.rb +28 -0
  35. data/app/mailers/decidim/stratified_sortitions/admin/import_mailer.rb +26 -0
  36. data/app/models/decidim/stratified_sortitions/application_record.rb +9 -0
  37. data/app/models/decidim/stratified_sortitions/panel_portfolio.rb +165 -0
  38. data/app/models/decidim/stratified_sortitions/sample_import.rb +14 -0
  39. data/app/models/decidim/stratified_sortitions/sample_participant.rb +12 -0
  40. data/app/models/decidim/stratified_sortitions/sample_participant_stratum.rb +11 -0
  41. data/app/models/decidim/stratified_sortitions/stratified_sortition.rb +74 -0
  42. data/app/models/decidim/stratified_sortitions/stratum.rb +22 -0
  43. data/app/models/decidim/stratified_sortitions/substratum.rb +19 -0
  44. data/app/packs/entrypoints/decidim_stratified_sortitions.js +10 -0
  45. data/app/packs/entrypoints/decidim_stratified_sortitions_admin.js +9 -0
  46. data/app/packs/entrypoints/decidim_stratified_sortitions_admin.scss +1 -0
  47. data/app/packs/images/decidim/stratified_sortitions/icon.svg +1 -0
  48. data/app/packs/src/decidim/stratified_sortitions/application.js +3 -0
  49. data/app/packs/src/decidim/stratified_sortitions/results_tabs.js +59 -0
  50. data/app/packs/src/decidim/stratified_sortitions/stratum_fields.js +302 -0
  51. data/app/packs/src/decidim/stratified_sortitions/substratum_fields.js +165 -0
  52. data/app/packs/src/decidim/stratified_sortitions/upload_sample.js +16 -0
  53. data/app/packs/stylesheets/decidim/stratified_sortitions/admin/stratified_sortitions.scss +210 -0
  54. data/app/packs/stylesheets/decidim/stratified_sortitions/charts_pdf.scss +148 -0
  55. data/app/packs/stylesheets/stratified_sortitions.scss +56 -0
  56. data/app/permissions/decidim/stratified_sortitions/admin/permissions.rb +74 -0
  57. data/app/permissions/decidim/stratified_sortitions/permissions.rb +23 -0
  58. data/app/presenters/decidim/stratified_sortitions/admin_log/stratified_sortition_presenter.rb +35 -0
  59. data/app/queries/decidim/stratified_sortitions/filtered_stratified_sortitions.rb +39 -0
  60. data/app/serializers/decidim/stratified_sortitions/sortition_result_serializer.rb +116 -0
  61. data/app/services/decidim/stratified_sortitions/charts_pdf_generator.rb +96 -0
  62. data/app/services/decidim/stratified_sortitions/fair_sortition_service.rb +247 -0
  63. data/app/services/decidim/stratified_sortitions/leximin/constraint_builder.rb +180 -0
  64. data/app/services/decidim/stratified_sortitions/leximin/distribution_solver.rb +217 -0
  65. data/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb +222 -0
  66. data/app/services/decidim/stratified_sortitions/leximin/panel_generator.rb +149 -0
  67. data/app/services/decidim/stratified_sortitions/leximin/panel_sampler.rb +125 -0
  68. data/app/services/decidim/stratified_sortitions/leximin_selector.rb +136 -0
  69. data/app/services/decidim/stratified_sortitions/sortition_results_exporter.rb +161 -0
  70. data/app/views/decidim/admin/components/index.html.erb +54 -0
  71. data/app/views/decidim/stratified_sortitions/admin/import_mailer/import.html.erb +22 -0
  72. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_execute_modal.html.erb +19 -0
  73. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_participants_modal.html.erb +20 -0
  74. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_form.html.erb +55 -0
  75. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_navigation_menu.html.erb +10 -0
  76. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_strata.html.erb +22 -0
  77. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_stratum.html.erb +75 -0
  78. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_substrata.html.erb +34 -0
  79. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_substratum.html.erb +54 -0
  80. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/edit.html.erb +21 -0
  81. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/execute.html.erb +155 -0
  82. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/export_charts_pdf.html.erb +38 -0
  83. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/index.html.erb +75 -0
  84. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/new.html.erb +21 -0
  85. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/upload_sample.html.erb +117 -0
  86. data/app/views/decidim/stratified_sortitions/shared/_strata_charts.html.erb +21 -0
  87. data/app/views/decidim/stratified_sortitions/stratified_sortitions/_results_count.html.erb +15 -0
  88. data/app/views/decidim/stratified_sortitions/stratified_sortitions/_stratified_sortition.html.erb +1 -0
  89. data/app/views/decidim/stratified_sortitions/stratified_sortitions/_stratified_sortitions.html.erb +13 -0
  90. data/app/views/decidim/stratified_sortitions/stratified_sortitions/_tags.html.erb +9 -0
  91. data/app/views/decidim/stratified_sortitions/stratified_sortitions/index.html.erb +22 -0
  92. data/app/views/decidim/stratified_sortitions/stratified_sortitions/index.js.erb +5 -0
  93. data/app/views/decidim/stratified_sortitions/stratified_sortitions/show.html.erb +142 -0
  94. data/app/views/layouts/decidim/admin/_sidebar_menu.html.erb +45 -0
  95. data/app/views/layouts/decidim/stratified_sortitions/charts_pdf.html.erb +14 -0
  96. data/config/assets.rb +24 -0
  97. data/config/locales/ca.yml +361 -0
  98. data/config/locales/en.yml +360 -0
  99. data/config/locales/es.yml +360 -0
  100. data/config/locales/oc.yml +2 -0
  101. data/config/routes.rb +1 -0
  102. data/db/migrate/20251023103900_create_decidim_stratified_sortitions_stratified_sortitions.rb +18 -0
  103. data/db/migrate/20251104120000_create_stratified_sortitions_strata_and_substrata.decidim_stratified_sortitions.rb +27 -0
  104. data/db/migrate/20251219120000_create_sample_imports.rb +17 -0
  105. data/db/migrate/20251219120100_create_sample_participants.rb +19 -0
  106. data/db/migrate/20251219120200_create_sample_participant_strata.rb +16 -0
  107. data/db/migrate/20251230130000_change_value_to_text_in_substrata.rb +11 -0
  108. data/db/migrate/20260102114744_add_status_to_stratified_sortitions.rb +11 -0
  109. data/db/migrate/20260109105643_allow_null_value_in_substrata.rb +10 -0
  110. data/db/migrate/20260113121726_add_position_to_strata_and_substrata.rb +8 -0
  111. data/db/migrate/20260127100000_rename_weighing_to_max_quota_percentage.rb +12 -0
  112. data/db/migrate/20260127120000_create_panel_portfolios.rb +46 -0
  113. data/db/migrate/20260518000000_add_foreign_key_to_stratified_sortitions_component.rb +10 -0
  114. data/db/migrate/20260521000000_add_cascade_to_stratified_sortitions_foreign_keys.rb +64 -0
  115. data/db/migrate/20260616000000_add_execution_error_to_stratified_sortitions.rb +11 -0
  116. data/lib/decidim/stratified_sortitions/admin.rb +10 -0
  117. data/lib/decidim/stratified_sortitions/admin_engine.rb +49 -0
  118. data/lib/decidim/stratified_sortitions/component.rb +41 -0
  119. data/lib/decidim/stratified_sortitions/engine.rb +34 -0
  120. data/lib/decidim/stratified_sortitions/seeds.rb +24 -0
  121. data/lib/decidim/stratified_sortitions/test/factories.rb +80 -0
  122. data/lib/decidim/stratified_sortitions/version.rb +13 -0
  123. data/lib/decidim/stratified_sortitions.rb +29 -0
  124. 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