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,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module AdminLog
6
+ # This class holds the logic to present a `Decidim::StratifiedSortitions::StratifiedSortition`
7
+ # for the `AdminLog` log.
8
+ #
9
+ # Usage should be automatic and you should not need to call this class
10
+ # directly, but here is an example:
11
+ #
12
+ # action_log = Decidim::ActionLog.last
13
+ # view_helpers # => this comes from the views
14
+ # StratifiedSortitionPresenter.new(action_log, view_helpers).present
15
+ class StratifiedSortitionPresenter < Decidim::Log::BasePresenter
16
+ private
17
+
18
+ def action_string
19
+ case action
20
+ when "create", "update", "delete", "duplicate",
21
+ "execute", "export_results", "view_participants",
22
+ "import_sample", "remove_samples"
23
+ "decidim.stratified_sortitions.admin_log.stratified_sortition.#{action}"
24
+ else
25
+ super
26
+ end
27
+ end
28
+
29
+ def i18n_labels_scope
30
+ "activemodel.attributes.stratified_sortition"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ # A class used to find stratified sortitions filtered by components and a date range
6
+ class FilteredStratifiedSortitions < Decidim::Query
7
+ # Syntactic sugar to initialize the class and return the queried objects.
8
+ #
9
+ # components - An array of Decidim::Component
10
+ # start_at - A date to filter resources created after it
11
+ # end_at - A date to filter resources created before it.
12
+ def self.for(components, start_at = nil, end_at = nil)
13
+ new(components, start_at, end_at).query
14
+ end
15
+
16
+ # Initializes the class.
17
+ #
18
+ # components - An array of Decidim::Component
19
+ # start_at - A date to filter resources created after it
20
+ # end_at - A date to filter resources created before it.
21
+ # rubocop:disable Lint/MissingSuper
22
+ def initialize(components, start_at = nil, end_at = nil)
23
+ @components = components
24
+ @start_at = start_at
25
+ @end_at = end_at
26
+ end
27
+ # rubocop:enable Lint/MissingSuper
28
+
29
+ # Finds the StratifiedSortitions scoped to an array of components and filtered
30
+ # by a range of dates.
31
+ def query
32
+ stratified_sortitions = Decidim::StratifiedSortitions::StratifiedSortition.where(component: @components)
33
+ stratified_sortitions = stratified_sortitions.where(created_at: @start_at..) if @start_at.present?
34
+ stratified_sortitions = stratified_sortitions.where(created_at: ..@end_at) if @end_at.present?
35
+ stratified_sortitions
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ # Serializer for sortition results export.
6
+ # Includes sortition metadata only in the first row and participant data in each row.
7
+ class SortitionResultSerializer < Decidim::Exporters::Serializer
8
+ # Reset the metadata flag before each export run.
9
+ def self.reset!
10
+ @metadata_serialized = false
11
+ end
12
+
13
+ def self.metadata_serialized?
14
+ @metadata_serialized == true
15
+ end
16
+
17
+ def self.metadata_serialized!
18
+ @metadata_serialized = true
19
+ end
20
+
21
+ # @return [Hash] serialized data for one participant with sortition metadata (only first row)
22
+ def serialize
23
+ participant = resource
24
+ portfolio = participant.decidim_stratified_sortition.panel_portfolio
25
+ audit_log = portfolio.audit_log
26
+
27
+ data = if self.class.metadata_serialized?
28
+ metadata_blank(audit_log)
29
+ else
30
+ self.class.metadata_serialized!
31
+ metadata_filled(audit_log)
32
+ end
33
+
34
+ data.merge!(participant_data(participant))
35
+ data
36
+ end
37
+
38
+ private
39
+
40
+ def metadata_filled(audit_log)
41
+ {
42
+ algorithm: audit_log[:algorithm],
43
+ version: audit_log[:version],
44
+ stratified_sortition_id: audit_log[:stratified_sortition_id],
45
+ generated_at: audit_log[:generated_at],
46
+ generation_time_seconds: audit_log[:generation_time_seconds],
47
+ num_panels: audit_log[:num_panels],
48
+ num_iterations: audit_log[:num_iterations],
49
+ convergence_achieved: audit_log[:convergence_achieved],
50
+ selected_at: audit_log[:selected_at],
51
+ selected_panel_index: audit_log[:selected_panel_index],
52
+ verification_seed: audit_log[:verification_seed],
53
+ random_value_used: audit_log[:random_value_used],
54
+ selected_panel_probability: audit_log[:selected_panel_probability],
55
+ }
56
+ end
57
+
58
+ def metadata_blank(_audit_log)
59
+ {
60
+ algorithm: nil,
61
+ version: nil,
62
+ stratified_sortition_id: nil,
63
+ generated_at: nil,
64
+ generation_time_seconds: nil,
65
+ num_panels: nil,
66
+ num_iterations: nil,
67
+ convergence_achieved: nil,
68
+ selected_at: nil,
69
+ selected_panel_index: nil,
70
+ verification_seed: nil,
71
+ random_value_used: nil,
72
+ selected_panel_probability: nil,
73
+ }
74
+ end
75
+
76
+ def participant_data(participant)
77
+ data = personal_data_fields(participant)
78
+ add_strata_columns(data, participant)
79
+ add_fairness_metrics(data, participant)
80
+ data
81
+ end
82
+
83
+ def personal_data_fields(participant)
84
+ {
85
+ personal_data_1: participant.personal_data_1,
86
+ personal_data_2: participant.personal_data_2,
87
+ personal_data_3: participant.personal_data_3,
88
+ personal_data_4: participant.personal_data_4,
89
+ }
90
+ end
91
+
92
+ def add_strata_columns(data, participant)
93
+ strata = participant.decidim_stratified_sortition.strata.order(:position)
94
+ strata.each do |stratum|
95
+ ps = participant.sample_participant_strata.find { |s| s.decidim_stratified_sortitions_stratum_id == stratum.id }
96
+ data[:"stratum_#{stratum_key(stratum)}"] = substratum_name_for(ps)
97
+ end
98
+ end
99
+
100
+ def stratum_key(stratum)
101
+ stratum.name.values.compact.first || stratum.id.to_s
102
+ end
103
+
104
+ def substratum_name_for(participant_stratum)
105
+ participant_stratum&.decidim_stratified_sortitions_substratum&.name&.values&.compact&.first || "-"
106
+ end
107
+
108
+ def add_fairness_metrics(data, participant)
109
+ metrics = participant.decidim_stratified_sortition.panel_portfolio.audit_log[:fairness_metrics]
110
+ return if metrics.blank?
111
+
112
+ metrics.each { |key, value| data[:"fairness_#{key}"] = value }
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wicked_pdf"
4
+
5
+ module Decidim
6
+ module StratifiedSortitions
7
+ # Generates a PDF with comparative pie charts for a stratified sortition.
8
+ # Uses WickedPdf (wkhtmltopdf) to convert an HTML template to PDF
9
+ class ChartsPdfGenerator
10
+ def initialize(stratified_sortition, strata_data, candidates_data, results_data, locale: I18n.locale)
11
+ @stratified_sortition = stratified_sortition
12
+ @strata_data = strata_data
13
+ @candidates_data = candidates_data
14
+ @results_data = results_data
15
+ @locale = locale
16
+ end
17
+
18
+ def generate
19
+ I18n.with_locale(@locale) do
20
+ html = controller.render_to_string(
21
+ template: "decidim/stratified_sortitions/admin/stratified_sortitions/export_charts_pdf",
22
+ layout: "decidim/stratified_sortitions/charts_pdf",
23
+ assigns: template_assigns
24
+ )
25
+
26
+ WickedPdf.new.pdf_from_string(html, orientation: "Portrait", page_size: "A4")
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def template_assigns
33
+ {
34
+ title: t("pdf_title", name: translated_name(@stratified_sortition.title)),
35
+ subtitle: t("pdf_subtitle"),
36
+ belongs_to: t("pdf_belongs_to", space_name: participatory_space_name),
37
+ algorithm: t("pdf_algorithm", algorithm: algorithm_info),
38
+ executed_at: t("pdf_executed_at", date: execution_date),
39
+ no_data_text: t("pdf_no_data"),
40
+ col_target: t("target"),
41
+ col_candidates: t("candidates"),
42
+ col_results: t("results"),
43
+ strata_chart_data: build_strata_chart_data,
44
+ }
45
+ end
46
+
47
+ def build_strata_chart_data
48
+ @strata_data.each_with_index.map do |sd, idx|
49
+ {
50
+ name: translated_name(sd[:stratum].name),
51
+ target: sd[:chart_data],
52
+ candidates: @candidates_data[idx][:chart_data],
53
+ results: @results_data[idx][:chart_data],
54
+ }
55
+ end
56
+ end
57
+
58
+ def translated_name(hash)
59
+ return hash if hash.is_a?(String)
60
+
61
+ hash[@locale.to_s] || hash[hash.keys.first] || ""
62
+ end
63
+
64
+ def participatory_space_name
65
+ space = @stratified_sortition.participatory_space
66
+ translated_name(space.title)
67
+ rescue StandardError
68
+ "-"
69
+ end
70
+
71
+ def algorithm_info
72
+ portfolio = @stratified_sortition.panel_portfolio
73
+ return "-" unless portfolio&.sampled?
74
+
75
+ "#{portfolio.audit_log[:algorithm]} v#{portfolio.audit_log[:version]}"
76
+ end
77
+
78
+ def execution_date
79
+ portfolio = @stratified_sortition.panel_portfolio
80
+ return "-" unless portfolio&.sampled?
81
+
82
+ I18n.l(portfolio.selected_at, format: :decidim_short)
83
+ rescue StandardError
84
+ portfolio.selected_at.to_s
85
+ end
86
+
87
+ def t(key, **opts)
88
+ I18n.t(key, scope: "decidim.stratified_sortitions.admin.stratified_sortitions.execute", **opts)
89
+ end
90
+
91
+ def controller
92
+ @controller ||= ChartsPdfControllerHelper.new
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ # Complete Fair Sortition Service
6
+ #
7
+ # Orchestrates the full LEXIMIN-based fair sortition process:
8
+ # 1. Runs LEXIMIN algorithm to find optimal panel distribution
9
+ # 2. Samples a panel according to the fair probability distribution
10
+ # 3. Returns the selected participants
11
+ #
12
+ # Supports two modes:
13
+ # - Single-phase: Generate and sample in one call
14
+ # - Two-phase: Generate portfolio first, sample later (for public ceremonies)
15
+ #
16
+ # @example Basic usage (single-phase)
17
+ # result = FairSortitionService.new(stratified_sortition).call
18
+ # if result.success?
19
+ # result.selected_participants # Array of SampleParticipant records
20
+ # result.selection_log # Audit information
21
+ # end
22
+ #
23
+ # @example Two-phase usage (for public/auditable draws)
24
+ # service = FairSortitionService.new(stratified_sortition)
25
+ #
26
+ # # Phase 1: Generate portfolio (can be done in background)
27
+ # portfolio_result = service.generate_portfolio
28
+ # # Publish portfolio_result.portfolio.panels for transparency
29
+ #
30
+ # # Phase 2: Sample from portfolio (can be done publicly)
31
+ # final_result = service.sample_from_portfolio(verification_seed: SecureRandom.hex(64))
32
+ #
33
+ # @example With verification seed (for auditable draws)
34
+ # result = FairSortitionService.new(stratified_sortition, verification_seed: "public_seed_123").call
35
+ #
36
+ class FairSortitionService
37
+ # Result object containing all sortition outputs
38
+ Result = Struct.new(
39
+ :selected_participants,
40
+ :selected_participant_ids,
41
+ :selection_probabilities,
42
+ :portfolio,
43
+ :sampling_result,
44
+ :selection_log,
45
+ :success,
46
+ :error,
47
+ keyword_init: true
48
+ ) do
49
+ def success?
50
+ success
51
+ end
52
+ end
53
+
54
+ # Result for portfolio generation
55
+ PortfolioResult = Struct.new(:portfolio, :success, :error, keyword_init: true) do
56
+ def success?
57
+ success
58
+ end
59
+ end
60
+
61
+ def initialize(stratified_sortition, verification_seed: nil)
62
+ @stratified_sortition = stratified_sortition
63
+ @verification_seed = verification_seed
64
+ end
65
+
66
+ # Execute the complete fair sortition process (single-phase)
67
+ #
68
+ # This generates the portfolio and samples in one call.
69
+ # For two-phase process, use generate_portfolio and sample_from_portfolio.
70
+ #
71
+ # @return [Result] containing selected participants and audit log
72
+ def call
73
+ # Check if already sampled
74
+ return error_result(error: I18n.t("decidim.stratified_sortitions.errors.fair_sortition.already_performed")) if existing_portfolio&.sampled?
75
+
76
+ # Generate or retrieve portfolio
77
+ portfolio = find_or_generate_portfolio
78
+ return error_result(error: portfolio.error) unless portfolio.is_a?(PanelPortfolio)
79
+
80
+ # Sample from portfolio
81
+ sample_from_portfolio(verification_seed: @verification_seed)
82
+ end
83
+
84
+ # Generate portfolio without sampling (Phase 1 of two-phase process)
85
+ #
86
+ # Creates a PanelPortfolio with all candidate panels and their probabilities.
87
+ # The portfolio is persisted to the database for later sampling.
88
+ #
89
+ # @return [PortfolioResult] containing the generated portfolio
90
+ def generate_portfolio
91
+ if existing_portfolio.present?
92
+ return PortfolioResult.new(
93
+ portfolio: existing_portfolio,
94
+ success: true,
95
+ error: nil
96
+ )
97
+ end
98
+
99
+ start_time = Time.current
100
+ leximin_result = LeximinSelector.new(@stratified_sortition).call
101
+
102
+ unless leximin_result.success?
103
+ return PortfolioResult.new(
104
+ portfolio: nil,
105
+ success: false,
106
+ error: leximin_result.error
107
+ )
108
+ end
109
+
110
+ @existing_portfolio = portfolio = PanelPortfolio.create!(
111
+ stratified_sortition: @stratified_sortition,
112
+ panels: leximin_result.panels,
113
+ probabilities: leximin_result.probabilities,
114
+ selection_probabilities: leximin_result.selection_probabilities,
115
+ generated_at: Time.current,
116
+ generation_time_seconds: Time.current - start_time,
117
+ num_iterations: leximin_result.panels.size, # Approximation
118
+ convergence_achieved: true
119
+ )
120
+
121
+ PortfolioResult.new(
122
+ portfolio:,
123
+ success: true,
124
+ error: nil
125
+ )
126
+ rescue StandardError => e
127
+ PortfolioResult.new(
128
+ portfolio: nil,
129
+ success: false,
130
+ error: I18n.t("decidim.stratified_sortitions.errors.fair_sortition.portfolio_generation_failed", error: e.message)
131
+ )
132
+ end
133
+
134
+ # Sample from existing portfolio (Phase 2 of two-phase process)
135
+ #
136
+ # @param verification_seed [String, nil] Optional seed for reproducible sampling
137
+ # @return [Result] containing selected participants and audit log
138
+ def sample_from_portfolio(verification_seed: nil)
139
+ portfolio = existing_portfolio
140
+
141
+ return error_result(error: I18n.t("decidim.stratified_sortitions.errors.fair_sortition.no_portfolio")) unless portfolio
142
+
143
+ if portfolio.sampled?
144
+ # Return existing result
145
+ return build_success_result(portfolio)
146
+ end
147
+
148
+ sampling_result = portfolio.sample!(verification_seed:)
149
+
150
+ return error_result(error: sampling_result.error) unless sampling_result.success?
151
+
152
+ build_success_result(portfolio.reload)
153
+ rescue StandardError => e
154
+ Rails.logger.error("Error while sampling portfolio: #{e.message}\n#{e.backtrace.join("\n")}")
155
+ error_result(error: I18n.t("decidim.stratified_sortitions.errors.fair_sortition.sampling_failed", error: e.message))
156
+ end
157
+
158
+ # Verify a previous sortition result
159
+ #
160
+ # Given the same seed and data, should produce the same result
161
+ #
162
+ # @param expected_ids [Array<Integer>] expected selected participant IDs
163
+ # @param verification_seed [String] the seed used in the original draw
164
+ # @return [Boolean] true if verification passes
165
+ def verify(expected_ids, verification_seed:)
166
+ portfolio = existing_portfolio
167
+ return false unless portfolio&.sampled?
168
+
169
+ # Re-sample with the same seed (doesn't persist)
170
+ random_seed = Decidim::StratifiedSortitions.derive_random_seed(verification_seed)
171
+ sampler = Leximin::PanelSampler.new(
172
+ portfolio.panels,
173
+ portfolio.probabilities,
174
+ random_seed:
175
+ )
176
+ result = sampler.sample
177
+ return false unless result.success?
178
+
179
+ Set.new(result.selected_panel) == Set.new(expected_ids)
180
+ end
181
+
182
+ # Check if this sortition has already been performed
183
+ #
184
+ # @return [Boolean]
185
+ def already_performed?
186
+ existing_portfolio&.sampled? || false
187
+ end
188
+
189
+ # Get the existing portfolio if any
190
+ #
191
+ # @return [PanelPortfolio, nil]
192
+ def existing_portfolio
193
+ @existing_portfolio ||= @stratified_sortition.panel_portfolio
194
+ end
195
+
196
+ private
197
+
198
+ def find_or_generate_portfolio
199
+ return existing_portfolio if existing_portfolio.present?
200
+
201
+ result = generate_portfolio
202
+ result.success? ? result.portfolio : result
203
+ end
204
+
205
+ def load_participants(participant_ids)
206
+ return [] if participant_ids.empty?
207
+
208
+ SampleParticipant
209
+ .where(id: participant_ids)
210
+ .order(:id)
211
+ .to_a
212
+ end
213
+
214
+ def build_success_result(portfolio)
215
+ Result.new(
216
+ selected_participants: portfolio.selected_participants,
217
+ selected_participant_ids: portfolio.selected_panel,
218
+ selection_probabilities: portfolio.selection_probabilities,
219
+ portfolio:,
220
+ sampling_result: Leximin::PanelSampler::Result.new(
221
+ selected_panel: portfolio.selected_panel,
222
+ selected_index: portfolio.selected_panel_index,
223
+ random_value: portfolio.random_value_used,
224
+ success: true,
225
+ error: nil
226
+ ),
227
+ selection_log: portfolio.audit_log,
228
+ success: true,
229
+ error: nil
230
+ )
231
+ end
232
+
233
+ def error_result(error:)
234
+ Result.new(
235
+ selected_participants: [],
236
+ selected_participant_ids: [],
237
+ selection_probabilities: {},
238
+ portfolio: existing_portfolio,
239
+ sampling_result: nil,
240
+ selection_log: { error: },
241
+ success: false,
242
+ error:
243
+ )
244
+ end
245
+ end
246
+ end
247
+ end