From 8c4e6a010988856c1020fdc95167248450e7ff79 Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Thu, 1 Aug 2024 18:27:41 +0000 Subject: [PATCH] Initial version of VNS algorithm (#8) Reviewed-on: https://gitea.bustikiller.com/bustikiller/wedding-planner/pulls/8 Co-authored-by: Manuel Bustillo Co-committed-by: Manuel Bustillo --- Gemfile | 4 +- Gemfile.lock | 9 +- .../tables_arrangements_controller.rb | 10 ++ app/helpers/tables_arrangements_helper.rb | 2 + app/models/seat.rb | 4 + app/models/tables_arrangement.rb | 3 + app/services/tables/discomfort_calculator.rb | 25 +++ app/services/tables/distribution.rb | 64 +++++++ app/services/tables/swap.rb | 28 ++++ app/services/vns/engine.rb | 48 ++++++ app/views/tables_arrangements/index.html.erb | 9 + app/views/tables_arrangements/show.html.erb | 16 ++ config/initializers/inflections.rb | 6 +- config/routes.rb | 9 +- ...240724181756_create_tables_arrangements.rb | 9 + db/migrate/20240724181853_create_seats.rb | 13 ++ db/schema.rb | 20 ++- db/seeds.rb | 13 +- lib/tasks/vns.rake | 18 ++ spec/factories/guest.rb | 8 + spec/models/seat_spec.rb | 5 + spec/models/tables_arrangement_spec.rb | 5 + spec/rails_helper.rb | 1 + .../tables/discomfort_calculator_spec.rb | 158 ++++++++++++++++++ 24 files changed, 463 insertions(+), 24 deletions(-) create mode 100644 app/controllers/tables_arrangements_controller.rb create mode 100644 app/helpers/tables_arrangements_helper.rb create mode 100644 app/models/seat.rb create mode 100644 app/models/tables_arrangement.rb create mode 100644 app/services/tables/discomfort_calculator.rb create mode 100644 app/services/tables/distribution.rb create mode 100644 app/services/tables/swap.rb create mode 100644 app/services/vns/engine.rb create mode 100644 app/views/tables_arrangements/index.html.erb create mode 100644 app/views/tables_arrangements/show.html.erb create mode 100644 db/migrate/20240724181756_create_tables_arrangements.rb create mode 100644 db/migrate/20240724181853_create_seats.rb create mode 100644 lib/tasks/vns.rake create mode 100644 spec/factories/guest.rb create mode 100644 spec/models/seat_spec.rb create mode 100644 spec/models/tables_arrangement_spec.rb create mode 100644 spec/services/tables/discomfort_calculator_spec.rb diff --git a/Gemfile b/Gemfile index e1f6e0d..45a5913 100644 --- a/Gemfile +++ b/Gemfile @@ -50,6 +50,7 @@ group :development, :test do gem 'rspec-rails', '~> 6.1.0' gem 'faker' gem 'pry' + gem "factory_bot_rails" end group :development do @@ -65,4 +66,5 @@ end gem "money" gem 'acts-as-taggable-on' -gem "rubytree" \ No newline at end of file + +gem "rubytree" diff --git a/Gemfile.lock b/Gemfile.lock index 3a48649..1b7e496 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -94,8 +94,12 @@ GEM diff-lcs (1.5.1) drb (2.2.1) erubi (1.13.0) - faker (3.4.2) - i18n (>= 1.8.11, < 2) + factory_bot (6.4.6) + activesupport (>= 5.0.0) + factory_bot_rails (6.4.3) + factory_bot (~> 6.4) + railties (>= 5.0.0) + faker (3.1.1) globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.5) @@ -265,6 +269,7 @@ DEPENDENCIES acts-as-taggable-on bootsnap debug + factory_bot_rails faker importmap-rails jbuilder diff --git a/app/controllers/tables_arrangements_controller.rb b/app/controllers/tables_arrangements_controller.rb new file mode 100644 index 0000000..d2fd9ac --- /dev/null +++ b/app/controllers/tables_arrangements_controller.rb @@ -0,0 +1,10 @@ +class TablesArrangementsController < ApplicationController + def index + @tables_arrangements = TablesArrangement.all.order(discomfort: :asc).limit(10) + end + + def show + @tables_arrangement = TablesArrangement.find(params[:id]) + @seats = @tables_arrangement.seats.includes(:guest).group_by(&:table_number) + end +end diff --git a/app/helpers/tables_arrangements_helper.rb b/app/helpers/tables_arrangements_helper.rb new file mode 100644 index 0000000..c066a2e --- /dev/null +++ b/app/helpers/tables_arrangements_helper.rb @@ -0,0 +1,2 @@ +module TablesArrangementsHelper +end diff --git a/app/models/seat.rb b/app/models/seat.rb new file mode 100644 index 0000000..e909587 --- /dev/null +++ b/app/models/seat.rb @@ -0,0 +1,4 @@ +class Seat < ApplicationRecord + belongs_to :guest + belongs_to :table_arrangement +end diff --git a/app/models/tables_arrangement.rb b/app/models/tables_arrangement.rb new file mode 100644 index 0000000..1be3ec3 --- /dev/null +++ b/app/models/tables_arrangement.rb @@ -0,0 +1,3 @@ +class TablesArrangement < ApplicationRecord + has_many :seats +end diff --git a/app/services/tables/discomfort_calculator.rb b/app/services/tables/discomfort_calculator.rb new file mode 100644 index 0000000..69214bb --- /dev/null +++ b/app/services/tables/discomfort_calculator.rb @@ -0,0 +1,25 @@ +module Tables + class DiscomfortCalculator + private attr_reader :table + def initialize(table) + @table = table + end + + def calculate + cohesion_penalty + end + + private + + def cohesion_penalty + table.map { |guest| guest.affinity_group_list.first }.tally.to_a.combination(2).sum do |(a, count_a), (b, count_b)| + distance = AffinityGroupsHierarchy.instance.distance(a, b) + + next count_a * count_b if distance.nil? + next 0 if distance.zero? + + count_a * count_b * Rational(distance, distance + 1) + end + end + end +end diff --git a/app/services/tables/distribution.rb b/app/services/tables/distribution.rb new file mode 100644 index 0000000..d10d73f --- /dev/null +++ b/app/services/tables/distribution.rb @@ -0,0 +1,64 @@ +require_relative '../../extensions/tree_node_extension' + +module Tables + class Distribution + attr_accessor :tables + + def initialize(min_per_table:, max_per_table:) + @min_per_table = min_per_table + @max_per_table = max_per_table + end + + def random_distribution(people) + @tables = [] + + @tables << people.slice!(0..rand(@min_per_table..@max_per_table)) while people.any? + end + + def discomfort + @tables.map do |table| + local_discomfort(table) + end.sum + end + + def inspect + "#{@tables.count} tables, discomfort: #{discomfort}" + end + + def pretty_print + @tables.map.with_index do |table, i| + "Table #{i + 1} (#{table.count} ppl): (#{local_discomfort(table)}) #{table.map(&:full_name).join(', ')}" + end.join("\n") + end + + def deep_dup + self.class.new(min_per_table: @min_per_table, max_per_table: @max_per_table).tap do |new_distribution| + new_distribution.tables = @tables.map(&:dup) + end + end + + def save! + ActiveRecord::Base.transaction do + arrangement = TablesArrangement.create! + + records_to_store = [] + + tables.each_with_index do |table, table_number| + table.each do |person| + records_to_store << { guest_id: person.id, tables_arrangement_id: arrangement.id, table_number: } + end + end + + Seat.insert_all!(records_to_store) + + arrangement.update!(discomfort:) + end + end + + private + + def local_discomfort(table) + DiscomfortCalculator.new(table).calculate + end + end +end diff --git a/app/services/tables/swap.rb b/app/services/tables/swap.rb new file mode 100644 index 0000000..ddc3681 --- /dev/null +++ b/app/services/tables/swap.rb @@ -0,0 +1,28 @@ +module Tables + class Swap + private attr_reader :initial_solution + def initialize(initial_solution) + @initial_solution = initial_solution + end + + def each + @initial_solution.tables.combination(2) do |table_a, table_b| + table_a.product(table_b).each do |(person_a, person_b)| + table_a.delete(person_a) + table_b.delete(person_b) + + table_a << person_b + table_b << person_a + + yield(@initial_solution) + ensure + table_a.delete(person_b) + table_b.delete(person_a) + + table_a << person_a + table_b << person_b + end + end + end + end +end diff --git a/app/services/vns/engine.rb b/app/services/vns/engine.rb new file mode 100644 index 0000000..5c260aa --- /dev/null +++ b/app/services/vns/engine.rb @@ -0,0 +1,48 @@ +module VNS + class Engine + def target_function(&function) + @target_function = function + end + + def add_perturbation(klass) + @perturbations ||= Set.new + @perturbations << klass + end + + attr_writer :initial_solution + + def run + raise 'No target function defined' unless @target_function + raise 'No perturbations defined' unless @perturbations + raise 'No initial solution defined' unless @initial_solution + + @best_solution = @initial_solution + @best_score = @target_function.call(@best_solution) + + puts "Initial score: #{@best_score.to_f}" + + @perturbations.each do |perturbation| + puts "Running perturbation: #{perturbation.name}" + optimize(perturbation.new(@best_solution)) + end + + @best_solution + end + + private + + def optimize(perturbation) + perturbation.each do |alternative_solution| + score = @target_function.call(alternative_solution) + next if score >= @best_score + + @best_solution = alternative_solution.deep_dup + @best_score = score + + puts "New lowest score: #{@best_score.to_f}" + + return optimize(perturbation.class.new(@best_solution)) + end + end + end +end diff --git a/app/views/tables_arrangements/index.html.erb b/app/views/tables_arrangements/index.html.erb new file mode 100644 index 0000000..260e144 --- /dev/null +++ b/app/views/tables_arrangements/index.html.erb @@ -0,0 +1,9 @@ +

Tables arrangements

+ +
    + <% @tables_arrangements.each_with_index do |tables_arrangement, i| %> +
  1. +

    <%= link_to "Arrangement ##{i+1}", tables_arrangement_path(tables_arrangement) %> Discomfort: <%= tables_arrangement.discomfort %>

    +
  2. + <% end %> +
\ No newline at end of file diff --git a/app/views/tables_arrangements/show.html.erb b/app/views/tables_arrangements/show.html.erb new file mode 100644 index 0000000..f43b80d --- /dev/null +++ b/app/views/tables_arrangements/show.html.erb @@ -0,0 +1,16 @@ +

ID: <%= @tables_arrangement.id %>

+ +

Discomfort: <%= @tables_arrangement.discomfort %>

+ +

Seats

+ +<% @seats.each do |table_number, seats| %> + +

Table <%= table_number %>

+ +
    + <% seats.each do |seat| %> +
  • <%= seat.guest.full_name %>
  • + <% end %> +
+<% end %> \ No newline at end of file diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 3860f65..157a851 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -11,6 +11,6 @@ # end # These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym "RESTful" -# end +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym 'VNS' +end diff --git a/config/routes.rb b/config/routes.rb index 821cba7..67f091d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,12 +3,7 @@ Rails.application.routes.draw do post :import, on: :collection end resources :expenses - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + resources :tables_arrangements, only: [:index, :show] - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. - get "up" => "rails/health#show", as: :rails_health_check - - # Defines the root path route ("/") - # root "posts#index" + get 'up' => 'rails/health#show', as: :rails_health_check end diff --git a/db/migrate/20240724181756_create_tables_arrangements.rb b/db/migrate/20240724181756_create_tables_arrangements.rb new file mode 100644 index 0000000..c05f6dc --- /dev/null +++ b/db/migrate/20240724181756_create_tables_arrangements.rb @@ -0,0 +1,9 @@ +class CreateTablesArrangements < ActiveRecord::Migration[7.1] + def change + create_table :tables_arrangements, id: :uuid do |t| + t.integer :discomfort + + t.timestamps + end + end +end diff --git a/db/migrate/20240724181853_create_seats.rb b/db/migrate/20240724181853_create_seats.rb new file mode 100644 index 0000000..74f5b7b --- /dev/null +++ b/db/migrate/20240724181853_create_seats.rb @@ -0,0 +1,13 @@ +class CreateSeats < ActiveRecord::Migration[7.1] + def change + create_table :seats, id: :uuid do |t| + t.references :guest, null: false, foreign_key: true, type: :uuid + t.references :tables_arrangement, null: false, type: :uuid + t.integer :table_number + + t.timestamps + end + + add_foreign_key :seats, :tables_arrangements, on_delete: :cascade + end +end diff --git a/db/schema.rb b/db/schema.rb index 7410759..08c4026 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_07_11_181632) do +ActiveRecord::Schema[7.1].define(version: 2024_07_24_181853) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -35,6 +35,22 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_11_181632) do t.datetime "updated_at", null: false end + create_table "seats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "guest_id", null: false + t.uuid "tables_arrangement_id", null: false + t.integer "table_number" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["guest_id"], name: "index_seats_on_guest_id" + t.index ["tables_arrangement_id"], name: "index_seats_on_tables_arrangement_id" + end + + create_table "tables_arrangements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.integer "discomfort" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "taggings", force: :cascade do |t| t.bigint "tag_id" t.string "taggable_type" @@ -66,5 +82,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_11_181632) do t.index ["name"], name: "index_tags_on_name", unique: true end + add_foreign_key "seats", "guests" + add_foreign_key "seats", "tables_arrangements", on_delete: :cascade add_foreign_key "taggings", "tags" end diff --git a/db/seeds.rb b/db/seeds.rb index 3f26f4c..162a80f 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,13 +1,6 @@ -# This file should ensure the existence of records required to run the application in every environment (production, -# development, test). The code here should be idempotent so that it can be executed at any point in every environment. -# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Example: -# -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| -# MovieGenre.find_or_create_by!(name: genre_name) -# end +NUMBER_OF_GUESTS = 50 +TablesArrangement.delete_all Expense.delete_all Guest.delete_all ActsAsTaggableOn::Tagging.delete_all @@ -53,7 +46,7 @@ samples = { count.times { acc << affinity_group } end -300.times do +NUMBER_OF_GUESTS.times do guest = Guest.create!(first_name: Faker::Name.first_name, last_name: Faker::Name.last_name, email: Faker::Internet.email, diff --git a/lib/tasks/vns.rake b/lib/tasks/vns.rake new file mode 100644 index 0000000..c8e6b50 --- /dev/null +++ b/lib/tasks/vns.rake @@ -0,0 +1,18 @@ +namespace :vns do + task distribute_tables: :environment do + engine = VNS::Engine.new + + engine.add_perturbation(Tables::Swap) + + initial_solution = Tables::Distribution.new(min_per_table: 8, max_per_table: 10) + initial_solution.random_distribution(Guest.all.shuffle) + + engine.initial_solution = initial_solution + + engine.target_function(&:discomfort) + + best_solution = engine.run + + best_solution.save! + end +end diff --git a/spec/factories/guest.rb b/spec/factories/guest.rb new file mode 100644 index 0000000..f761dd9 --- /dev/null +++ b/spec/factories/guest.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :guest do + first_name { Faker::Name.first_name } + last_name { Faker::Name.last_name } + email { Faker::Internet.email } + phone { Faker::PhoneNumber.cell_phone } + end +end diff --git a/spec/models/seat_spec.rb b/spec/models/seat_spec.rb new file mode 100644 index 0000000..bdcd95d --- /dev/null +++ b/spec/models/seat_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Seat, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/tables_arrangement_spec.rb b/spec/models/tables_arrangement_spec.rb new file mode 100644 index 0000000..71a09f5 --- /dev/null +++ b/spec/models/tables_arrangement_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe TablesArrangement, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index a15455f..c10abff 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -62,4 +62,5 @@ RSpec.configure do |config| config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + config.include FactoryBot::Syntax::Methods end diff --git a/spec/services/tables/discomfort_calculator_spec.rb b/spec/services/tables/discomfort_calculator_spec.rb new file mode 100644 index 0000000..3764e7e --- /dev/null +++ b/spec/services/tables/discomfort_calculator_spec.rb @@ -0,0 +1,158 @@ +require 'rails_helper' +module Tables + RSpec.describe DiscomfortCalculator do + let(:calculator) { described_class.new(table) } + + describe '#cohesion_penalty' do + before do + # Overridden in each test except trivial cases + allow(AffinityGroupsHierarchy.instance).to receive(:distance).and_call_original + + %w[family friends work school].each do |group| + allow(AffinityGroupsHierarchy.instance).to receive(:distance).with(group, group).and_return(0) + end + + allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('family', 'friends').and_return(nil) + allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('friends', 'work').and_return(1) + allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('family', 'work').and_return(2) + allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('family', 'school').and_return(3) + allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('friends', 'school').and_return(4) + allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('work', 'school').and_return(5) + end + context 'when the table contains just two guests' do + context 'when they belong to the same group' do + let(:table) { create_list(:guest, 2, affinity_group_list: ['family']) } + + it { expect(calculator.send(:cohesion_penalty)).to eq(0) } + end + + context 'when they belong to completely unrelated groups' do + let(:table) do + [ + create(:guest, affinity_group_list: ['family']), + create(:guest, affinity_group_list: ['friends']) + ] + end + it { expect(calculator.send(:cohesion_penalty)).to eq(1) } + end + + context 'when they belong to groups at a distance of 1' do + let(:table) do + [ + create(:guest, affinity_group_list: ['friends']), + create(:guest, affinity_group_list: ['work']) + ] + end + + it { expect(calculator.send(:cohesion_penalty)).to eq(0.5) } + end + + context 'when they belong to groups at a distance of 2' do + let(:table) do + [ + create(:guest, affinity_group_list: ['family']), + create(:guest, affinity_group_list: ['work']) + ] + end + + it { expect(calculator.send(:cohesion_penalty)).to eq(Rational(2, 3)) } + end + + context 'when they belong to groups at a distance of 3' do + let(:table) do + [ + create(:guest, affinity_group_list: ['family']), + create(:guest, affinity_group_list: ['school']) + ] + end + + it { expect(calculator.send(:cohesion_penalty)).to eq(Rational(3, 4)) } + end + end + + context 'when the table contains three guests' do + let(:table) do + [ + create(:guest, affinity_group_list: ['family']), + create(:guest, affinity_group_list: ['friends']), + create(:guest, affinity_group_list: ['work']) + ] + end + + it 'returns the sum of the penalties for each pair of guests' do + expect(calculator.send(:cohesion_penalty)).to eq(1 + Rational(1, 2) + Rational(2, 3)) + end + end + + context 'when the table contains four guests of different groups' do + let(:table) do + [ + create(:guest, affinity_group_list: ['family']), + create(:guest, affinity_group_list: ['friends']), + create(:guest, affinity_group_list: ['work']), + create(:guest, affinity_group_list: ['school']) + ] + end + + it 'returns the sum of the penalties for each pair of guests' do + expect(calculator.send(:cohesion_penalty)) + .to eq(1 + Rational(1, 2) + Rational(2, 3) + Rational(3, 4) + Rational(4, 5) + Rational(5, 6)) + end + end + + context 'when the table contains four guests of two evenly split groups' do + let(:table) do + [ + create_list(:guest, 2, affinity_group_list: ['family']), + create_list(:guest, 2, affinity_group_list: ['friends']) + ].flatten + end + + it 'returns the sum of the penalties for each pair of guests' do + expect(calculator.send(:cohesion_penalty)).to eq(4) + end + end + + context 'when the table contains six guests of two unevenly split groups' do + let(:table) do + [ + create_list(:guest, 2, affinity_group_list: ['family']), + create_list(:guest, 4, affinity_group_list: ['friends']) + ].flatten + end + + it 'returns the sum of the penalties for each pair of guests' do + expect(calculator.send(:cohesion_penalty)).to eq(8) + end + end + + context 'when the table contains six guests of three evenly split groups' do + let(:table) do + [ + create_list(:guest, 2, affinity_group_list: ['family']), + create_list(:guest, 2, affinity_group_list: ['friends']), + create_list(:guest, 2, affinity_group_list: ['work']) + ].flatten + end + + it 'returns the sum of the penalties for each pair of guests' do + expect(calculator.send(:cohesion_penalty)).to eq(4 * 1 + 4 * Rational(1, 2) + 4 * Rational(2, 3)) + end + end + + context 'when the table contains six guests of three unevenly split groups' do + let(:table) do + [ + create_list(:guest, 3, affinity_group_list: ['family']), + create_list(:guest, 2, affinity_group_list: ['friends']), + create_list(:guest, 1, affinity_group_list: ['work']) + ].flatten + end + + it 'returns the sum of the penalties for each pair of guests' do + expect(calculator.send(:cohesion_penalty)).to eq(6 * 1 + 2 * Rational(1, 2) + 3 * Rational(2, 3)) + end + end + end + end +end