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| %>
+ -
+
<%= link_to "Arrangement ##{i+1}", tables_arrangement_path(tables_arrangement) %> Discomfort: <%= tables_arrangement.discomfort %>
+
+ <% 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