Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down Expand Up @@ -151,6 +152,60 @@ 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

strata.each do |stratum|
errors.concat(validate_stratum_quotas(stratum))
end

errors
end

def validate_stratum_quotas(stratum)
errors = []
stratum_name = extract_name(stratum[:name])

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions config/locales/ca.yml
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,15 @@ 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:
already_performed: "El sorteig ja s'ha realitzat per aquest procés"
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."

4 changes: 4 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,14 @@ 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:
already_performed: "The sortition has already been performed for this process"
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."
4 changes: 4 additions & 0 deletions config/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,14 @@ 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:
already_performed: "El sorteo ya se ha realizado para este proceso"
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."
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading