From 47ee6c472ebf3ac0cd1631fbcda06e993fff5cb4 Mon Sep 17 00:00:00 2001 From: Ruben Date: Thu, 5 Mar 2026 16:01:05 +0100 Subject: [PATCH 1/2] Added personalized notifications for algorithm --- .../leximin/feasibility_checker.rb | 46 ++++ .../stratified_sortitions/leximin_selector.rb | 4 +- config/locales/ca.yml | 4 + config/locales/en.yml | 4 + config/locales/es.yml | 4 + .../leximin/feasibility_checker_spec.rb | 210 ++++++++++++++++++ .../leximin_selector_spec.rb | 8 +- 7 files changed, 277 insertions(+), 3 deletions(-) diff --git a/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb b/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb index 930fde6..bdd3a36 100644 --- a/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb +++ b/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb @@ -26,6 +26,7 @@ def check errors.concat(check_pool_size) errors.concat(check_quota_consistency) errors.concat(check_category_coverage) + errors.concat(check_cross_strata_feasibility) if errors.empty? { feasible: errors.empty?, @@ -151,6 +152,51 @@ def find_volunteers_without_complete_strata incomplete end + # Checks that the combination of max_quota_percentage constraints across all strata + # can be satisfied simultaneously. For each pair of substrata from different strata, + # there must be enough volunteers that belong to both categories. + def check_cross_strata_feasibility + errors = [] + k = @cb.panel_size + return errors if k.nil? || k <= 0 + + strata = @cb.strata_info + return errors if strata.size < 2 + + # For each stratum, check that the sum of volunteers per substratum + # that also appear in at least one substratum of every other stratum + # is enough to fill the panel + strata.each do |stratum| + stratum_name = extract_name(stratum[:name]) + + stratum[:substrata].each do |substratum| + cat_id = substratum[:id] + max_quota = substratum[:max_quota] + percentage = substratum[:percentage] + volunteers_in_cat = @cb.category_volunteers[cat_id]&.size || 0 + + # Skip if no restriction (percentage 0 means unrestricted), no quota, or no volunteers + next if percentage.zero? || max_quota.zero? || volunteers_in_cat.zero? + + # Check if this substratum's max quota exceeds available volunteers + next unless max_quota > volunteers_in_cat + + substratum_name = extract_name(substratum[:name]) + + errors << I18n.t( + "decidim.stratified_sortitions.errors.feasibility.substratum_quota_exceeds_volunteers", + substratum_name:, + stratum_name:, + max_quota:, + volunteers_count: volunteers_in_cat, + percentage: + ) + end + end + + errors + end + def extract_name(name_field) case name_field when Hash diff --git a/app/services/decidim/stratified_sortitions/leximin_selector.rb b/app/services/decidim/stratified_sortitions/leximin_selector.rb index c98805d..34dc5f8 100644 --- a/app/services/decidim/stratified_sortitions/leximin_selector.rb +++ b/app/services/decidim/stratified_sortitions/leximin_selector.rb @@ -58,7 +58,7 @@ def call # Step 2: Initialize with one feasible panel panel_generator = Leximin::PanelGenerator.new(@constraint_builder) initial_panel = panel_generator.find_feasible_panel - return infeasible_result("No s'ha pogut trobar cap panel inicial vàlid") if initial_panel.nil? + return infeasible_result(I18n.t("decidim.stratified_sortitions.errors.leximin.no_feasible_panel")) if initial_panel.nil? panels = [initial_panel] @@ -98,7 +98,7 @@ def call probabilities: [], selection_probabilities: {}, success: false, - error: "LEXIMIN internal error: #{e.message}" + error: I18n.t("decidim.stratified_sortitions.errors.leximin.internal_error", error: e.message) ) end diff --git a/config/locales/ca.yml b/config/locales/ca.yml index a9a482d..c719f91 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -287,6 +287,7 @@ ca: stratum_insufficient_quotas: "L'estrat '%{stratum_name}' té quotes màximes insuficients. La suma de quotes màximes (%{total_max}) és menor que la mida del panell (%{panel_size})" stratum_percentages_exceed: "L'estrat '%{stratum_name}' té percentatges que sumen més del 100%% (%{total_percentage}%%)" substratum_insufficient_volunteers: "El substrat '%{substratum_name}' de l'estrat '%{stratum_name}' requereix mínim %{min_quota} voluntaris però només n'hi ha %{volunteers_count}" + substratum_quota_exceeds_volunteers: "El substrat '%{substratum_name}' de l'estrat '%{stratum_name}' té una quota màxima de %{max_quota} (%{percentage}%%) però només %{volunteers_count} voluntari(s) pertanyen a aquesta categoria. Reduïu el percentatge de quota màxima o afegiu més participants en aquesta categoria" volunteers_missing_strata: "Hi ha %{count} voluntari(s) que no tenen assignat un substrat per a cada estrat. Això pot causar problemes en l'algorisme de selecció" no_name: "Sense nom" fair_sortition: @@ -294,4 +295,7 @@ ca: portfolio_generation_failed: "Error generant la cartera de panells: %{error}" no_portfolio: "No hi ha cartera de panels. Executeu `generate_portfolio` primer" sampling_failed: "Error en el mostreig: %{error}" + leximin: + no_feasible_panel: "No s'ha pogut trobar cap panell inicial vàlid. La combinació de restriccions de quota (percentatges màxims) i la mostra disponible fan impossible formar un panell. Possibles causes: (1) Els percentatges de quota màxima d'alguns substrats són massa restrictius per a la mida de la mostra disponible. (2) No hi ha prou participants en certes combinacions de substrats. (3) El nombre de candidats a seleccionar és massa gran en relació a la mostra. Proveu d'ajustar els percentatges de quota màxima o carregueu una mostra més gran." + internal_error: "Error intern en l'algorisme de selecció: %{error}. Si us plau, verifiqueu que la configuració dels estrats i els percentatges de quota màxima són compatibles amb la mostra carregada." diff --git a/config/locales/en.yml b/config/locales/en.yml index 8be1073..25ac88f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -287,6 +287,7 @@ en: stratum_insufficient_quotas: "Stratum '%{stratum_name}' has insufficient maximum quotas. The sum of maximum quotas (%{total_max}) is less than the panel size (%{panel_size})" stratum_percentages_exceed: "Stratum '%{stratum_name}' has percentages that sum to more than 100%% (%{total_percentage}%%)" substratum_insufficient_volunteers: "Substratum '%{substratum_name}' of stratum '%{stratum_name}' requires a minimum of %{min_quota} volunteers but only has %{volunteers_count}" + substratum_quota_exceeds_volunteers: "Substratum '%{substratum_name}' of stratum '%{stratum_name}' has a max quota of %{max_quota} (%{percentage}%%) but only %{volunteers_count} volunteer(s) belong to this category. Reduce the max quota percentage or add more participants in this category" volunteers_missing_strata: "There are %{count} volunteer(s) that do not have a substratum assigned for each stratum. This may cause problems in the selection algorithm" no_name: "No name" fair_sortition: @@ -294,3 +295,6 @@ en: portfolio_generation_failed: "Error generating panel portfolio: %{error}" no_portfolio: "There is no panel portfolio. Execute `generate_portfolio` first" sampling_failed: "Error while sampling: %{error}" + leximin: + no_feasible_panel: "Could not find a valid initial panel. The combination of quota constraints (max percentages) and the available sample makes it impossible to form a panel. Possible causes: (1) The max quota percentages for some substrata are too restrictive for the available sample size. (2) There are not enough participants in certain substratum combinations. (3) The number of candidates to select is too large relative to the sample. Try adjusting the max quota percentages or uploading a larger sample." + internal_error: "Internal error in the selection algorithm: %{error}. Please verify that the strata configuration and max quota percentages are compatible with the loaded sample." diff --git a/config/locales/es.yml b/config/locales/es.yml index b0d57ec..d2daa5a 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -287,6 +287,7 @@ es: stratum_insufficient_quotas: "El estrato '%{stratum_name}' tiene cuotas máximas insuficientes. La suma de cuotas máximas (%{total_max}) es menor que el tamaño del panel (%{panel_size})" stratum_percentages_exceed: "El estrato '%{stratum_name}' tiene porcentajes que suman más del 100%% (%{total_percentage}%%)" substratum_insufficient_volunteers: "El subestrato '%{substratum_name}' del estrato '%{stratum_name}' requiere un mínimo de %{min_quota} voluntarios pero solo tiene %{volunteers_count}" + substratum_quota_exceeds_volunteers: "El subestrato '%{substratum_name}' del estrato '%{stratum_name}' tiene una cuota máxima de %{max_quota} (%{percentage}%%) pero solo %{volunteers_count} voluntario(s) pertenecen a esta categoría. Reduzca el porcentaje de cuota máxima o añada más participantes en esta categoría" volunteers_missing_strata: "Hay %{count} voluntario(s) que no tienen asignado un subestrato para cada estrato. Esto puede causar problemas en el algoritmo de selección" no_name: "Sin nombre" fair_sortition: @@ -294,3 +295,6 @@ es: portfolio_generation_failed: "Error generando la cartera de paneles: %{error}" no_portfolio: "No hay cartera de paneles. Ejecute `generate_portfolio` primero" sampling_failed: "Error en el muestreo: %{error}" + leximin: + no_feasible_panel: "No se pudo encontrar un panel inicial válido. La combinación de restricciones de cuota (porcentajes máximos) y la muestra disponible hacen imposible formar un panel. Posibles causas: (1) Los porcentajes de cuota máxima de algunos subestratos son demasiado restrictivos para el tamaño de la muestra disponible. (2) No hay suficientes participantes en ciertas combinaciones de subestratos. (3) El número de candidatos a seleccionar es demasiado grande en relación a la muestra. Intente ajustar los porcentajes de cuota máxima o cargue una muestra más grande." + internal_error: "Error interno en el algoritmo de selección: %{error}. Por favor, verifique que la configuración de los estratos y los porcentajes de cuota máxima son compatibles con la muestra cargada." diff --git a/spec/services/decidim/stratified_sortitions/leximin/feasibility_checker_spec.rb b/spec/services/decidim/stratified_sortitions/leximin/feasibility_checker_spec.rb index c00733b..16bc68b 100644 --- a/spec/services/decidim/stratified_sortitions/leximin/feasibility_checker_spec.rb +++ b/spec/services/decidim/stratified_sortitions/leximin/feasibility_checker_spec.rb @@ -85,6 +85,216 @@ module Leximin end end end + + describe "#check_cross_strata_feasibility" do + # Helper to create a sortition with controlled participant assignment per substratum. + # assignments is: { substratum_object => number_of_participants } + # Each participant is assigned to exactly one substratum per stratum. + def create_sortition_with_controlled_assignments(panel_size:, strata_with_substrata:, assignments:) + sortition = create(:stratified_sortition, num_candidates: panel_size) + + strata_objects = strata_with_substrata.map.with_index do |stratum_config, idx| + stratum = create(:stratum, + stratified_sortition: sortition, + name: { en: stratum_config[:name] }, + kind: "value", + position: idx) + + substrata = stratum_config[:substrata].map.with_index do |sub_config, sub_idx| + create(:substratum, + stratum:, + name: { en: sub_config[:name] }, + value: sub_config[:name], + max_quota_percentage: sub_config[:percentage].to_s, + position: sub_idx) + end + + { stratum:, substrata: } + end + + # Create participants and assign them to specific substrata + participant_id_counter = 0 + assignments.each do |substratum_index_map, count| + count.times do + participant = create(:sample_participant, + decidim_stratified_sortition: sortition, + personal_data_1: "p_#{participant_id_counter}") + participant_id_counter += 1 + + substratum_index_map.each do |stratum_idx, substratum_idx| + stratum_data = strata_objects[stratum_idx] + create(:sample_participant_stratum, + decidim_stratified_sortitions_sample_participant: participant, + decidim_stratified_sortitions_stratum: stratum_data[:stratum], + decidim_stratified_sortitions_substratum: stratum_data[:substrata][substratum_idx], + raw_value: stratum_data[:substrata][substratum_idx].value) + end + end + end + + sortition.reload + end + + context "when a substratum max quota exceeds its volunteers" do + # Panel: 20, Stratum "Age" has substratum "Young" at 40% → max_quota = ceil(0.4*20) = 8 + # But only 5 participants belong to "Young" + let(:sortition) do + create_sortition_with_controlled_assignments( + panel_size: 20, + strata_with_substrata: [ + { name: "Gender", substrata: [{ name: "Male", percentage: 50 }, { name: "Female", percentage: 50 }] }, + { name: "Age", substrata: [{ name: "Young", percentage: 40 }, { name: "Old", percentage: 60 }] }, + ], + assignments: { + # { stratum_idx => substratum_idx } => count + { 0 => 0, 1 => 0 } => 5, # 5 Male+Young + { 0 => 0, 1 => 1 } => 5, # 5 Male+Old + { 0 => 1, 1 => 1 } => 10, # 10 Female+Old + } + ) + end + + it "returns infeasible with substratum_quota_exceeds_volunteers error" do + result = checker.check + expect(result[:feasible]).to be false + expect(result[:errors].join).to include("Young") + expect(result[:errors].join).to include("Age") + end + end + + context "when multiple substrata have quotas exceeding their volunteers" do + # Panel: 10 + # "Male" at 50% → max_quota = ceil(0.5*10) = 5, but only 4 Male participants + # "North" at 40% → max_quota = ceil(0.4*10) = 4, but only 3 North participants + # Percentages within each stratum sum to ≤100%, so quota_consistency passes + let(:sortition) do + create_sortition_with_controlled_assignments( + panel_size: 10, + strata_with_substrata: [ + { name: "Gender", substrata: [{ name: "Male", percentage: 50 }, { name: "Female", percentage: 50 }] }, + { name: "Zone", substrata: [{ name: "North", percentage: 40 }, { name: "South", percentage: 60 }] }, + ], + assignments: { + { 0 => 0, 1 => 0 } => 2, # 2 Male+North + { 0 => 0, 1 => 1 } => 2, # 2 Male+South → total Male = 4 + { 0 => 1, 1 => 0 } => 1, # 1 Female+North → total North = 3 + { 0 => 1, 1 => 1 } => 15, # 15 Female+South + } + ) + end + + it "returns multiple errors" do + result = checker.check + expect(result[:feasible]).to be false + quota_errors = result[:errors].select { |e| e.include?("quota") || e.include?("cuota") || e.include?("quota") } + expect(quota_errors.size).to be >= 2 + end + + it "mentions both problematic substrata" do + result = checker.check + errors_text = result[:errors].join("; ") + expect(errors_text).to include("Male") + expect(errors_text).to include("North") + end + end + + context "when substratum has percentage 0 (unrestricted)" do + # percentage 0 → max_quota = panel_size → should NOT trigger error even if few volunteers + let(:sortition) do + create_sortition_with_controlled_assignments( + panel_size: 10, + strata_with_substrata: [ + { name: "Gender", substrata: [{ name: "Male", percentage: 50 }, { name: "Female", percentage: 50 }] }, + { name: "Age", substrata: [{ name: "Young", percentage: 0 }, { name: "Old", percentage: 0 }] }, + ], + assignments: { + { 0 => 0, 1 => 0 } => 2, # 2 Male+Young + { 0 => 0, 1 => 1 } => 3, # 3 Male+Old + { 0 => 1, 1 => 0 } => 2, # 2 Female+Young + { 0 => 1, 1 => 1 } => 3, # 3 Female+Old + } + ) + end + + it "does not flag substrata with percentage 0" do + result = checker.check + quota_errors = result[:errors].select { |e| e.include?("quota") || e.include?("cuota") } + expect(quota_errors).to be_empty + end + end + + context "when there is only one stratum (skips cross-strata check)" do + # Only 1 stratum → check_cross_strata_feasibility returns early + let(:sortition) do + create_sortition_with_controlled_assignments( + panel_size: 10, + strata_with_substrata: [ + { name: "Gender", substrata: [{ name: "Male", percentage: 80 }, { name: "Female", percentage: 80 }] }, + ], + assignments: { + { 0 => 0 } => 3, # 3 Male + { 0 => 1 } => 7, # 7 Female + } + ) + end + + it "does not run cross-strata check" do + result = checker.check + # The quota check might still catch issues, but not via cross-strata + quota_exceeds_errors = result[:errors].select { |e| e.include?("volunteer(s) belong") || e.include?("voluntari") } + expect(quota_exceeds_errors).to be_empty + end + end + + context "when quotas are within available volunteers (valid)" do + # All substrata have enough volunteers for their max_quota + let(:sortition) do + create_sortition_with_controlled_assignments( + panel_size: 10, + strata_with_substrata: [ + { name: "Gender", substrata: [{ name: "Male", percentage: 50 }, { name: "Female", percentage: 50 }] }, + { name: "Age", substrata: [{ name: "Young", percentage: 30 }, { name: "Old", percentage: 70 }] }, + ], + assignments: { + { 0 => 0, 1 => 0 } => 10, # 10 Male+Young + { 0 => 0, 1 => 1 } => 10, # 10 Male+Old + { 0 => 1, 1 => 0 } => 10, # 10 Female+Young + { 0 => 1, 1 => 1 } => 10, # 10 Female+Old + } + ) + end + + it "returns feasible" do + result = checker.check + expect(result[:feasible]).to be true + expect(result[:errors]).to be_empty + end + end + + context "when ceil effect pushes quota above volunteers" do + # Panel: 15, "Local" at 10% → max_quota = ceil(0.1*15) = 2, but only 1 volunteer + let(:sortition) do + create_sortition_with_controlled_assignments( + panel_size: 15, + strata_with_substrata: [ + { name: "Gender", substrata: [{ name: "Male", percentage: 50 }, { name: "Female", percentage: 50 }] }, + { name: "Origin", substrata: [{ name: "Local", percentage: 10 }, { name: "Foreign", percentage: 90 }] }, + ], + assignments: { + { 0 => 0, 1 => 0 } => 1, # 1 Male+Local + { 0 => 0, 1 => 1 } => 7, # 7 Male+Foreign + { 0 => 1, 1 => 1 } => 7, # 7 Female+Foreign + } + ) + end + + it "detects the ceil-induced infeasibility" do + result = checker.check + expect(result[:feasible]).to be false + expect(result[:errors].join).to include("Local") + end + end + end end end end diff --git a/spec/services/decidim/stratified_sortitions/leximin_selector_spec.rb b/spec/services/decidim/stratified_sortitions/leximin_selector_spec.rb index 5cdbb31..7f8eb20 100644 --- a/spec/services/decidim/stratified_sortitions/leximin_selector_spec.rb +++ b/spec/services/decidim/stratified_sortitions/leximin_selector_spec.rb @@ -107,6 +107,12 @@ module StratifiedSortitions let(:result) { selector.call } it_behaves_like "a failed leximin result" + + it "returns an i18n error message" do + expect(result.error).to be_present + # Should not contain raw hardcoded strings + expect(result.error).not_to include("LEXIMIN internal error:") + end end context "with no participants" do @@ -122,7 +128,7 @@ module StratifiedSortitions it_behaves_like "a failed leximin result" it "includes relevant error message" do - expect(result.error).to include("No volunteers in the pool.") + expect(result.error).to include(I18n.t("decidim.stratified_sortitions.errors.feasibility.no_volunteers")) end end From eb1f2bbe6d58ac769845ae99a884b3d63047c0db Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 6 Mar 2026 13:44:57 +0100 Subject: [PATCH 2/2] Fix lints --- .../leximin/feasibility_checker.rb | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb b/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb index bdd3a36..c75249a 100644 --- a/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb +++ b/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb @@ -163,40 +163,49 @@ def check_cross_strata_feasibility strata = @cb.strata_info return errors if strata.size < 2 - # For each stratum, check that the sum of volunteers per substratum - # that also appear in at least one substratum of every other stratum - # is enough to fill the panel strata.each do |stratum| - stratum_name = extract_name(stratum[:name]) - - stratum[:substrata].each do |substratum| - cat_id = substratum[:id] - max_quota = substratum[:max_quota] - percentage = substratum[:percentage] - volunteers_in_cat = @cb.category_volunteers[cat_id]&.size || 0 - - # Skip if no restriction (percentage 0 means unrestricted), no quota, or no volunteers - next if percentage.zero? || max_quota.zero? || volunteers_in_cat.zero? + errors.concat(validate_stratum_quotas(stratum)) + end - # Check if this substratum's max quota exceeds available volunteers - next unless max_quota > volunteers_in_cat + errors + end - substratum_name = extract_name(substratum[:name]) + def validate_stratum_quotas(stratum) + errors = [] + stratum_name = extract_name(stratum[:name]) - errors << I18n.t( - "decidim.stratified_sortitions.errors.feasibility.substratum_quota_exceeds_volunteers", - substratum_name:, - stratum_name:, - max_quota:, - volunteers_count: volunteers_in_cat, - percentage: - ) - end + stratum[:substrata].each do |substratum| + error = validate_substratum_quota(substratum, stratum_name) + errors << error if error.present? end errors end + def validate_substratum_quota(substratum, stratum_name) + cat_id = substratum[:id] + max_quota = substratum[:max_quota] + percentage = substratum[:percentage] + volunteers_in_cat = @cb.category_volunteers[cat_id]&.size || 0 + + # Skip if no restriction (percentage 0 means unrestricted), no quota, or no volunteers + return nil if percentage.zero? || max_quota.zero? || volunteers_in_cat.zero? + + # Check if this substratum's max quota exceeds available volunteers + return nil unless max_quota > volunteers_in_cat + + substratum_name = extract_name(substratum[:name]) + + I18n.t( + "decidim.stratified_sortitions.errors.feasibility.substratum_quota_exceeds_volunteers", + substratum_name:, + stratum_name:, + max_quota:, + volunteers_count: volunteers_in_cat, + percentage: + ) + end + def extract_name(name_field) case name_field when Hash