Initial version of VNS algorithm (#8)
All checks were successful
Run unit tests / unit_tests (push) Successful in 3m36s

Reviewed-on: #8
Co-authored-by: Manuel Bustillo <bustikiller@bustikiller.com>
Co-committed-by: Manuel Bustillo <bustikiller@bustikiller.com>
This commit is contained in:
Manuel Bustillo 2024-08-01 18:27:41 +00:00 committed by bustikiller
parent 55939658a5
commit 8c4e6a0109
24 changed files with 463 additions and 24 deletions

View File

@ -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"
gem "rubytree"

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
module TablesArrangementsHelper
end

4
app/models/seat.rb Normal file
View File

@ -0,0 +1,4 @@
class Seat < ApplicationRecord
belongs_to :guest
belongs_to :table_arrangement
end

View File

@ -0,0 +1,3 @@
class TablesArrangement < ApplicationRecord
has_many :seats
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,9 @@
<h1>Tables arrangements</h1>
<ol>
<% @tables_arrangements.each_with_index do |tables_arrangement, i| %>
<li>
<p><%= link_to "Arrangement ##{i+1}", tables_arrangement_path(tables_arrangement) %> Discomfort: <%= tables_arrangement.discomfort %></p>
</li>
<% end %>
</ol>

View File

@ -0,0 +1,16 @@
<h1>ID: <%= @tables_arrangement.id %></h1>
<p>Discomfort: <%= @tables_arrangement.discomfort %></p>
<h2>Seats</h2>
<% @seats.each do |table_number, seats| %>
<h3>Table <%= table_number %></h3>
<ul>
<% seats.each do |seat| %>
<li><%= seat.guest.full_name %></li>
<% end %>
</ul>
<% end %>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

20
db/schema.rb generated
View File

@ -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

View File

@ -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,

18
lib/tasks/vns.rake Normal file
View File

@ -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

8
spec/factories/guest.rb Normal file
View File

@ -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

5
spec/models/seat_spec.rb Normal file
View File

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe Seat, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe TablesArrangement, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@ -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

View File

@ -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