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/tables/wheel_swap.rb b/app/services/tables/wheel_swap.rb new file mode 100644 index 0000000..b7d21b8 --- /dev/null +++ b/app/services/tables/wheel_swap.rb @@ -0,0 +1,33 @@ +# 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(size = 1) + Rails.logger.debug { "WheelSwap with size: #{size}" } + new_solution = @initial_solution.deep_dup + + 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 diff --git a/app/services/vns/engine.rb b/app/services/vns/engine.rb index d66c80b..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 @@ -15,6 +16,11 @@ module VNS @target_function = function end + def add_optimization(klass) + @optimizations ||= Set.new + @optimizations << klass + end + def add_perturbation(klass) @perturbations ||= Set.new @perturbations << klass @@ -24,32 +30,58 @@ module VNS 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) + @perturbations ||= Set.new - self.class.sequence(@perturbations).each do |perturbation| - optimize(perturbation) + @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(PERTURBATION_SIZES.sample) + @best_score = @target_function.call(@current_solution) + Rails.logger.debug { "After perturbation: #{@best_score}" } + + run_all_optimizations + + 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 + best_solution end private - def optimize(perturbation_klass) + 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' } + end + + def optimize(optimization_klass) loop do optimized = false - perturbation_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}" } break end 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/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:) diff --git a/spec/services/tables/wheel_swap_spec.rb b/spec/services/tables/wheel_swap_spec.rb new file mode 100644 index 0000000..02dc161 --- /dev/null +++ b/spec/services/tables/wheel_swap_spec.rb @@ -0,0 +1,28 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +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) + expect(result.tables.map(&:size)).to all(eq(3)) + + expect(result.tables.map(&:to_a).flatten).to match_array((:a..:i).to_a) + end + end + end +end