From e1a5e4f73e0e7da204847021bd06e92be20bbcb3 Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Tue, 22 Jul 2025 15:39:30 +0200 Subject: [PATCH 01/10] Rename perturbation -> optimization to reflect the nature of swap and shift operations --- app/jobs/table_simulator_job.rb | 4 ++-- app/services/vns/engine.rb | 16 ++++++++-------- lib/tasks/vns.rake | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/jobs/table_simulator_job.rb b/app/jobs/table_simulator_job.rb index 51b7256..0471795 100644 --- a/app/jobs/table_simulator_job.rb +++ b/app/jobs/table_simulator_job.rb @@ -12,8 +12,8 @@ class TableSimulatorJob < ApplicationJob ActsAsTenant.with_tenant(Wedding.find(wedding_id)) do engine = VNS::Engine.new - engine.add_perturbation(Tables::Swap) - engine.add_perturbation(Tables::Shift) + engine.add_optimization(Tables::Swap) + engine.add_optimization(Tables::Shift) initial_solution = Tables::Distribution.new(min_per_table: MIN_PER_TABLE, max_per_table: MAX_PER_TABLE) initial_solution.random_distribution(Guest.potential.shuffle) diff --git a/app/services/vns/engine.rb b/app/services/vns/engine.rb index d66c80b..075ad1a 100644 --- a/app/services/vns/engine.rb +++ b/app/services/vns/engine.rb @@ -15,23 +15,23 @@ module VNS @target_function = function end - def add_perturbation(klass) - @perturbations ||= Set.new - @perturbations << klass + def add_optimization(klass) + @optimizations ||= Set.new + @optimizations << klass end attr_writer :initial_solution def run raise 'No target function defined' unless @target_function - raise 'No perturbations defined' unless @perturbations + raise 'No optimizations defined' unless @optimizations raise 'No initial solution defined' unless @initial_solution @best_solution = @initial_solution @best_score = @target_function.call(@best_solution) - self.class.sequence(@perturbations).each do |perturbation| - optimize(perturbation) + self.class.sequence(@optimizations).each do |optimization| + optimize(optimization) end @best_solution @@ -39,11 +39,11 @@ module VNS private - def optimize(perturbation_klass) + def optimize(optimization_klass) loop do optimized = false - perturbation_klass.new(@best_solution).each do |alternative_solution| + optimization_klass.new(@best_solution).each do |alternative_solution| score = @target_function.call(alternative_solution) next if score >= @best_score diff --git a/lib/tasks/vns.rake b/lib/tasks/vns.rake index b8c7b3a..9554a76 100644 --- a/lib/tasks/vns.rake +++ b/lib/tasks/vns.rake @@ -8,8 +8,8 @@ namespace :vns do engine = VNS::Engine.new - engine.add_perturbation(Tables::Swap) - engine.add_perturbation(Tables::Shift) + engine.add_optimization(Tables::Swap) + engine.add_optimization(Tables::Shift) hierarchy = AffinityGroupsHierarchy.new initial_solution = Tables::Distribution.new(min_per_table: 8, max_per_table: 10, hierarchy:) From 543b53d93858a548086113507b24a98010ac5a4c Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Tue, 22 Jul 2025 15:53:06 +0200 Subject: [PATCH 02/10] Initialize empty set of perturbations and add debug messages --- app/services/vns/engine.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/services/vns/engine.rb b/app/services/vns/engine.rb index 075ad1a..ff8758b 100644 --- a/app/services/vns/engine.rb +++ b/app/services/vns/engine.rb @@ -20,6 +20,11 @@ module VNS @optimizations << klass end + def add_perturbation(klass) + @perturbations ||= Set.new + @perturbations << klass + end + attr_writer :initial_solution def run @@ -27,13 +32,18 @@ module VNS raise 'No optimizations defined' unless @optimizations raise 'No initial solution defined' unless @initial_solution + @perturbations ||= Set.new + @best_solution = @initial_solution @best_score = @target_function.call(@best_solution) self.class.sequence(@optimizations).each do |optimization| optimize(optimization) + Rails.logger.debug { "Finished optimization phase: #{optimization.name}" } end + Rails.logger.debug { "Finished all optimization phases" } + @best_solution end From 185f35994217f073f19cb8cad4cb511b4cb04580 Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Tue, 22 Jul 2025 15:56:25 +0200 Subject: [PATCH 03/10] Include additional debugging messages --- app/services/vns/engine.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/services/vns/engine.rb b/app/services/vns/engine.rb index ff8758b..f76ec14 100644 --- a/app/services/vns/engine.rb +++ b/app/services/vns/engine.rb @@ -39,7 +39,7 @@ module VNS self.class.sequence(@optimizations).each do |optimization| optimize(optimization) - Rails.logger.debug { "Finished optimization phase: #{optimization.name}" } + Rails.logger.debug { "Finished optimization phase: #{optimization}" } end Rails.logger.debug { "Finished all optimization phases" } @@ -60,6 +60,7 @@ module VNS @best_solution = alternative_solution.deep_dup @best_score = score optimized = true + Rails.logger.debug { "[#{optimization_klass}] Found better solution with score: #{score}" } break end From e8a88b50e2750abfa4198d317e990a5583379183 Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Tue, 22 Jul 2025 16:26:28 +0200 Subject: [PATCH 04/10] Initialize Tables::WheelSwap class --- app/services/tables/wheel_swap.rb | 16 ++++++++++++++++ spec/services/tables/wheel_swap_spec.rb | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 app/services/tables/wheel_swap.rb create mode 100644 spec/services/tables/wheel_swap_spec.rb diff --git a/app/services/tables/wheel_swap.rb b/app/services/tables/wheel_swap.rb new file mode 100644 index 0000000..d932537 --- /dev/null +++ b/app/services/tables/wheel_swap.rb @@ -0,0 +1,16 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +module Tables + class WheelSwap + private attr_reader :initial_solution + def initialize(initial_solution) + @initial_solution = initial_solution + end + + def call + @initial_solution.deep_dup + end + end +end \ No newline at end of file diff --git a/spec/services/tables/wheel_swap_spec.rb b/spec/services/tables/wheel_swap_spec.rb new file mode 100644 index 0000000..19c6b0b --- /dev/null +++ b/spec/services/tables/wheel_swap_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +module Tables + RSpec.describe WheelSwap do + context "when the solution has three tables" do + let(:initial_solution) do + Distribution.new(min_per_table: 3, max_per_table: 3).tap do |distribution| + distribution.tables << Set[:a, :b, :c].to_table + distribution.tables << Set[:d, :e, :f].to_table + distribution.tables << Set[:g, :h, :i].to_table + end + end + + it "swaps a random guest from each table with a guest from another table", :aggregate_failures do + result = described_class.new(initial_solution).call + + expect(result.tables.size).to eq(3) + end + end + end +end \ No newline at end of file From a1f06dae5bb0b1d3ff4f9c3193f6ab8ba21c02ba Mon Sep 17 00:00:00 2001 From: bustikiller Date: Wed, 23 Jul 2025 10:22:41 +0200 Subject: [PATCH 05/10] Define a WheelSwap class that randomly swaps one guest from each table --- app/services/tables/wheel_swap.rb | 7 ++++++- config/initializers/ruby_extensions.rb | 6 ++++++ spec/services/tables/wheel_swap_spec.rb | 7 +++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/services/tables/wheel_swap.rb b/app/services/tables/wheel_swap.rb index d932537..b5171a1 100644 --- a/app/services/tables/wheel_swap.rb +++ b/app/services/tables/wheel_swap.rb @@ -10,7 +10,12 @@ module Tables end def call - @initial_solution.deep_dup + new_solution = @initial_solution.deep_dup + + selected_guests = new_solution.tables.map(&:pop).cycle.tap(&:next) + new_solution.tables.each { |table| table << selected_guests.next } + + new_solution end end end \ No newline at end of file diff --git a/config/initializers/ruby_extensions.rb b/config/initializers/ruby_extensions.rb index 47091de..594fa59 100644 --- a/config/initializers/ruby_extensions.rb +++ b/config/initializers/ruby_extensions.rb @@ -10,4 +10,10 @@ class Set def to_table Tables::Table.new(self) end + + def pop + element = self.to_a.sample + self.delete(element) + element + end end \ No newline at end of file diff --git a/spec/services/tables/wheel_swap_spec.rb b/spec/services/tables/wheel_swap_spec.rb index 19c6b0b..20913fd 100644 --- a/spec/services/tables/wheel_swap_spec.rb +++ b/spec/services/tables/wheel_swap_spec.rb @@ -15,6 +15,13 @@ module Tables result = described_class.new(initial_solution).call expect(result.tables.size).to eq(3) + expect(result.tables.map(&:size)).to all(eq(3)) + + expect(result.tables).not_to include(initial_solution.tables[0]) + expect(result.tables).not_to include(initial_solution.tables[1]) + expect(result.tables).not_to include(initial_solution.tables[2]) + + expect(result.tables.map(&:to_a).flatten).to contain_exactly(*(:a..:i).to_a) end end end From b1df5d2f53644ea2179cf2f4098cf8444546abf9 Mon Sep 17 00:00:00 2001 From: bustikiller Date: Wed, 23 Jul 2025 11:01:38 +0200 Subject: [PATCH 06/10] Shuffle guests in WheelSwap before assigning them to new tables --- app/services/tables/wheel_swap.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/services/tables/wheel_swap.rb b/app/services/tables/wheel_swap.rb index b5171a1..fea238b 100644 --- a/app/services/tables/wheel_swap.rb +++ b/app/services/tables/wheel_swap.rb @@ -11,9 +11,10 @@ module Tables def call new_solution = @initial_solution.deep_dup - - selected_guests = new_solution.tables.map(&:pop).cycle.tap(&:next) + + selected_guests = new_solution.tables.map(&:pop).shuffle.cycle new_solution.tables.each { |table| table << selected_guests.next } + new_solution.tables.each(&:reset) new_solution end From 4befb8505b6fe33126e015332be5b21d3d500791 Mon Sep 17 00:00:00 2001 From: bustikiller Date: Wed, 23 Jul 2025 11:02:32 +0200 Subject: [PATCH 07/10] Run perturbation on top of the best solution so far --- app/services/vns/engine.rb | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/app/services/vns/engine.rb b/app/services/vns/engine.rb index f76ec14..40adbfc 100644 --- a/app/services/vns/engine.rb +++ b/app/services/vns/engine.rb @@ -34,30 +34,49 @@ module VNS @perturbations ||= Set.new - @best_solution = @initial_solution - @best_score = @target_function.call(@best_solution) + @current_solution = @initial_solution + @best_score = @target_function.call(@current_solution) + run_all_optimizations + + best_solution = @current_solution + + 50.times do + @current_solution = Tables::WheelSwap.new(best_solution).call + @best_score = @target_function.call(@current_solution) + Rails.logger.debug { "After perturbation: #{@best_score}" } + + run_all_optimizations + + if best_solution.discomfort > @current_solution.discomfort + best_solution = @current_solution + Rails.logger.debug { "Found better solution after perturbation optimization: #{@current_solution.discomfort}" } + end + end + + + best_solution + end + + private + + def run_all_optimizations self.class.sequence(@optimizations).each do |optimization| optimize(optimization) Rails.logger.debug { "Finished optimization phase: #{optimization}" } end - Rails.logger.debug { "Finished all optimization phases" } - - @best_solution end - private - def optimize(optimization_klass) loop do optimized = false - optimization_klass.new(@best_solution).each do |alternative_solution| + optimization_klass.new(@current_solution).each do |alternative_solution| score = @target_function.call(alternative_solution) next if score >= @best_score - @best_solution = alternative_solution.deep_dup + @current_solution = alternative_solution.deep_dup @best_score = score optimized = true Rails.logger.debug { "[#{optimization_klass}] Found better solution with score: #{score}" } From db85580c1faec0a4beb659e9f41c8857c718541f Mon Sep 17 00:00:00 2001 From: bustikiller Date: Thu, 24 Jul 2025 13:41:01 +0200 Subject: [PATCH 08/10] Introduce a wheel swap perturbation as part of the VNS engine process --- app/services/tables/wheel_swap.rb | 19 +++++++++++++++---- app/services/vns/engine.rb | 14 ++++++++------ spec/services/tables/wheel_swap_spec.rb | 20 +++++++++++--------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/app/services/tables/wheel_swap.rb b/app/services/tables/wheel_swap.rb index fea238b..b7d21b8 100644 --- a/app/services/tables/wheel_swap.rb +++ b/app/services/tables/wheel_swap.rb @@ -9,14 +9,25 @@ module Tables @initial_solution = initial_solution end - def call + def call(size = 1) + Rails.logger.debug { "WheelSwap with size: #{size}" } new_solution = @initial_solution.deep_dup - selected_guests = new_solution.tables.map(&:pop).shuffle.cycle - new_solution.tables.each { |table| table << selected_guests.next } + selected_guests = [] + + size.times do + selected_guests += new_solution.tables.map(&:pop) + end + + selected_guests.shuffle! + + tables = new_solution.tables.cycle + + tables.next << selected_guests.pop while selected_guests.any? + new_solution.tables.each(&:reset) new_solution end end -end \ No newline at end of file +end diff --git a/app/services/vns/engine.rb b/app/services/vns/engine.rb index 40adbfc..dc0feff 100644 --- a/app/services/vns/engine.rb +++ b/app/services/vns/engine.rb @@ -4,6 +4,7 @@ module VNS class Engine + PERTURBATION_SIZES = [1, 1, 1, 2, 2, 3].freeze class << self def sequence(elements) elements = elements.to_a @@ -42,19 +43,20 @@ module VNS best_solution = @current_solution 50.times do - @current_solution = Tables::WheelSwap.new(best_solution).call + @current_solution = Tables::WheelSwap.new(best_solution).call(PERTURBATION_SIZES.sample) @best_score = @target_function.call(@current_solution) Rails.logger.debug { "After perturbation: #{@best_score}" } run_all_optimizations - if best_solution.discomfort > @current_solution.discomfort - best_solution = @current_solution - Rails.logger.debug { "Found better solution after perturbation optimization: #{@current_solution.discomfort}" } + next unless best_solution.discomfort > @current_solution.discomfort + + best_solution = @current_solution + Rails.logger.debug do + "Found better solution after perturbation optimization: #{@current_solution.discomfort}" end end - best_solution end @@ -65,7 +67,7 @@ module VNS optimize(optimization) Rails.logger.debug { "Finished optimization phase: #{optimization}" } end - Rails.logger.debug { "Finished all optimization phases" } + Rails.logger.debug { 'Finished all optimization phases' } end def optimize(optimization_klass) diff --git a/spec/services/tables/wheel_swap_spec.rb b/spec/services/tables/wheel_swap_spec.rb index 20913fd..e30c5fc 100644 --- a/spec/services/tables/wheel_swap_spec.rb +++ b/spec/services/tables/wheel_swap_spec.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + require 'rails_helper' module Tables RSpec.describe WheelSwap do - context "when the solution has three tables" do + context 'when the solution has three tables' do let(:initial_solution) do Distribution.new(min_per_table: 3, max_per_table: 3).tap do |distribution| distribution.tables << Set[:a, :b, :c].to_table @@ -10,19 +12,19 @@ module Tables distribution.tables << Set[:g, :h, :i].to_table end end - - it "swaps a random guest from each table with a guest from another table", :aggregate_failures do + + it 'swaps a random guest from each table with a guest from another table', :aggregate_failures do result = described_class.new(initial_solution).call - + expect(result.tables.size).to eq(3) expect(result.tables.map(&:size)).to all(eq(3)) - expect(result.tables).not_to include(initial_solution.tables[0]) - expect(result.tables).not_to include(initial_solution.tables[1]) - expect(result.tables).not_to include(initial_solution.tables[2]) + # expect(result.tables).not_to include(initial_solution.tables[0]) + # expect(result.tables).not_to include(initial_solution.tables[1]) + # expect(result.tables).not_to include(initial_solution.tables[2]) - expect(result.tables.map(&:to_a).flatten).to contain_exactly(*(:a..:i).to_a) + expect(result.tables.map(&:to_a).flatten).to match_array((:a..:i).to_a) end end end -end \ No newline at end of file +end From d18bddb31a0e52fc39e274e2e285a6e3eddf29c2 Mon Sep 17 00:00:00 2001 From: bustikiller Date: Thu, 24 Jul 2025 13:42:47 +0200 Subject: [PATCH 09/10] Remove commented-out code --- spec/services/tables/wheel_swap_spec.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/spec/services/tables/wheel_swap_spec.rb b/spec/services/tables/wheel_swap_spec.rb index e30c5fc..de5fc5b 100644 --- a/spec/services/tables/wheel_swap_spec.rb +++ b/spec/services/tables/wheel_swap_spec.rb @@ -19,10 +19,6 @@ module Tables expect(result.tables.size).to eq(3) expect(result.tables.map(&:size)).to all(eq(3)) - # expect(result.tables).not_to include(initial_solution.tables[0]) - # expect(result.tables).not_to include(initial_solution.tables[1]) - # expect(result.tables).not_to include(initial_solution.tables[2]) - expect(result.tables.map(&:to_a).flatten).to match_array((:a..:i).to_a) end end From 3260b0b422015018b81b0e4ecd9a404ae5431bdb Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Thu, 24 Jul 2025 11:46:28 +0000 Subject: [PATCH 10/10] Add copyright notice --- spec/services/tables/wheel_swap_spec.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spec/services/tables/wheel_swap_spec.rb b/spec/services/tables/wheel_swap_spec.rb index de5fc5b..02dc161 100644 --- a/spec/services/tables/wheel_swap_spec.rb +++ b/spec/services/tables/wheel_swap_spec.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # frozen_string_literal: true require 'rails_helper'