Compare commits

...

10 Commits

Author SHA1 Message Date
Renovate Bot
8b965c82d6 Update dependency jbuilder to v2.14.1
All checks were successful
Run unit tests / check-licenses (pull_request) Successful in 2m6s
Run unit tests / rubocop (pull_request) Successful in 2m7s
Run unit tests / copyright_notice (pull_request) Successful in 2m22s
Run unit tests / unit_tests (pull_request) Successful in 4m47s
Run unit tests / build-static-assets (pull_request) Successful in 21m5s
2025-09-16 22:16:53 +00:00
e28751521d Merge pull request 'Persist and expose via API the progress of the tables arrangement simulations' (#316) from arrangements-status into main
All checks were successful
Run unit tests / rubocop (push) Has been skipped
Run unit tests / check-licenses (push) Has been skipped
Run unit tests / copyright_notice (push) Has been skipped
Run unit tests / unit_tests (push) Successful in 9m29s
Run unit tests / build-static-assets (push) Successful in 22m38s
Reviewed-on: #316
2025-09-16 00:39:40 +00:00
0502bc4552
Disable a rubocop alert
All checks were successful
Run unit tests / rubocop (pull_request) Successful in 3m14s
Run unit tests / check-licenses (pull_request) Successful in 5m37s
Run unit tests / copyright_notice (pull_request) Successful in 7m13s
Run unit tests / unit_tests (pull_request) Successful in 30m24s
Run unit tests / build-static-assets (pull_request) Successful in 2h21m47s
2025-09-15 23:17:00 +02:00
7d8ecfd0e3
Refactor class to reduce complexity of #run method
Some checks failed
Run unit tests / rubocop (pull_request) Failing after 2m17s
Run unit tests / check-licenses (pull_request) Successful in 3m51s
Run unit tests / copyright_notice (pull_request) Successful in 4m4s
Run unit tests / unit_tests (pull_request) Successful in 10m37s
Run unit tests / build-static-assets (pull_request) Failing after 56s
2025-09-15 23:04:02 +02:00
78ab27a697
Fix specs
Some checks failed
Run unit tests / copyright_notice (pull_request) Successful in 1m50s
Run unit tests / rubocop (pull_request) Failing after 2m12s
Run unit tests / check-licenses (pull_request) Successful in 2m50s
Run unit tests / unit_tests (pull_request) Successful in 3m48s
Run unit tests / build-static-assets (pull_request) Successful in 1h45m6s
2025-09-15 22:52:41 +02:00
12174b6f20 Persist VNS calculation progress whenever an improvement has been made
Some checks failed
Run unit tests / check-licenses (pull_request) Failing after 1m44s
Run unit tests / rubocop (pull_request) Failing after 1m46s
Run unit tests / copyright_notice (pull_request) Successful in 2m8s
Run unit tests / unit_tests (pull_request) Failing after 3m30s
Run unit tests / build-static-assets (pull_request) Has been skipped
2025-09-08 22:44:54 +02:00
0d1b64256d Provide notification callbacks for progress and new solutions 2025-09-08 16:32:13 +02:00
ac659bef86 Update Tables::Distribution#save! to consider that the distribution may already be persisted 2025-09-08 15:51:43 +02:00
dd14a96e98 Expose and document the new status attribute in the tables arrangements controller 2025-08-01 12:29:13 +02:00
75a0191d40 Add a new status column to tables arrangements table 2025-08-01 12:25:43 +02:00
19 changed files with 164 additions and 45 deletions

View File

@ -145,9 +145,9 @@ GEM
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.13.0)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.13.2)
json-schema (5.0.1)
addressable (~> 2.8)
@ -211,18 +211,18 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.9)
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-gnu)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-arm-linux-gnu)
nokogiri (1.18.10-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-darwin)
nokogiri (1.18.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
orm_adapter (0.5.0)
ostruct (0.6.2)
@ -539,7 +539,7 @@ CHECKSUMS
importmap-rails (2.2.2) sha256=729f5b1092f832780829ade1d0b46c7e53d91c556f06da7254da2977e93fe614
io-console (0.8.1) sha256=1e15440a6b2f67b6ea496df7c474ed62c860ad11237f29b3bd187f054b925fcb
irb (1.15.2) sha256=222f32952e278da34b58ffe45e8634bf4afc2dc7aa9da23fed67e581aa50fdba
jbuilder (2.13.0) sha256=7200a38a1c0081aa81b7a9757e7a299db75bc58cf1fd45ca7919a91627d227d6
jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42
json (2.13.2) sha256=02e1f118d434c6b230a64ffa5c8dee07e3ec96244335c392eaed39e1199dbb68
json-schema (5.0.1) sha256=bef71a82c600a42594911553522e143f7634affc198ed507ef3ded2f920a74a9
jsonapi-deserializable (0.2.0) sha256=5f0ca2d3f8404cce1584a314e8a3753be32a56054c942adfe997b87e92bce147
@ -570,12 +570,12 @@ CHECKSUMS
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
nio4r (2.7.4) sha256=d95dee68e0bb251b8ff90ac3423a511e3b784124e5db7ff5f4813a220ae73ca9
nokogiri (1.18.9) sha256=ac5a7d93fd0e3cef388800b037407890882413feccca79eb0272a2715a82fa33
nokogiri (1.18.9-aarch64-linux-gnu) sha256=5bcfdf7aa8d1056a7ad5e52e1adffc64ef53d12d0724fbc6f458a3af1a4b9e32
nokogiri (1.18.9-arm-linux-gnu) sha256=fe611ae65880e445a9c0f650d52327db239f3488626df4173c05beafd161d46e
nokogiri (1.18.9-arm64-darwin) sha256=eea3f1f06463ff6309d3ff5b88033c4948d0da1ab3cc0a3a24f63c4d4a763979
nokogiri (1.18.9-x86_64-darwin) sha256=e0d2deb03d3d7af8016e8c9df5ff4a7d692159cefb135cbb6a4109f265652348
nokogiri (1.18.9-x86_64-linux-gnu) sha256=b52f5defedc53d14f71eeaaf990da66b077e1918a2e13088b6a96d0230f44360
nokogiri (1.18.10) sha256=d5cc0731008aa3b3a87b361203ea3d19b2069628cb55e46ac7d84a0445e69cc1
nokogiri (1.18.10-aarch64-linux-gnu) sha256=7fb87235d729c74a2be635376d82b1d459230cc17c50300f8e4fcaabc6195344
nokogiri (1.18.10-arm-linux-gnu) sha256=51f4f25ab5d5ba1012d6b16aad96b840a10b067b93f35af6a55a2c104a7ee322
nokogiri (1.18.10-arm64-darwin) sha256=c2b0de30770f50b92c9323fa34a4e1cf5a0af322afcacd239cd66ee1c1b22c85
nokogiri (1.18.10-x86_64-darwin) sha256=536e74bed6db2b5076769cab5e5f5af0cd1dccbbd75f1b3e1fa69d1f5c2d79e2
nokogiri (1.18.10-x86_64-linux-gnu) sha256=ff5ba26ba2dbce5c04b9ea200777fd225061d7a3930548806f31db907e500f72
orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9
ostruct (0.6.2) sha256=6d7302a299e400a2c248d6ce0dad18fc3a5714e8096facc25ffd0c54ee57cfc0
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130

View File

@ -9,10 +9,10 @@ class TablesArrangementsController < ApplicationController
render json: TablesArrangement
.order(valid: :desc)
.order(discomfort: :asc)
.select(:id, :name, :discomfort)
.select("digest = '#{current_digest}'::uuid as valid")
.select(:id, :name, :discomfort, :status, :progress)
.select("digest = '#{current_digest}'::uuid OR discomfort IS NULL as valid")
.limit(20)
.as_json(only: %i[id name discomfort valid])
.as_json(only: %i[id name discomfort valid status progress])
end
def show
@ -25,7 +25,10 @@ class TablesArrangementsController < ApplicationController
end
def create
TableSimulatorJob.perform_later(current_tenant.id)
ActiveRecord::Base.transaction do
tables_arrangement = TablesArrangement.create!(status: :not_started)
TableSimulatorJob.perform_later(current_tenant.id, tables_arrangement.id)
end
render json: {}, status: :created
end

View File

@ -8,16 +8,35 @@ class TableSimulatorJob < ApplicationJob
MIN_PER_TABLE = 8
MAX_PER_TABLE = 10
def perform(wedding_id)
def perform(wedding_id, tables_arrangement_id) # rubocop:disable Metrics/MethodLength
Rails.logger.info "Starting table simulation #{tables_arrangement_id} for wedding #{wedding_id}"
ActsAsTenant.with_tenant(Wedding.find(wedding_id)) do
engine = VNS::Engine.new
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)
tables_arrangement = TablesArrangement.find(tables_arrangement_id)
initial_solution = Tables::Distribution.new(
min_per_table: MIN_PER_TABLE,
max_per_table: MAX_PER_TABLE,
tables_arrangement_id:
)
initial_solution.random_distribution(Guest.potential.shuffle)
initial_solution.save!
engine.notify_progress do |current_progress|
tables_arrangement.update_columns(status: :in_progress, progress: current_progress)
end
engine.on_better_solution do |better_solution|
better_solution.save!
tables_arrangement.update_columns(discomfort: better_solution.discomfort) # TODO: remove?
end
engine.initial_solution = initial_solution
engine.target_function(&:discomfort)
@ -25,6 +44,8 @@ class TableSimulatorJob < ApplicationJob
best_solution = engine.run
best_solution.save!
tables_arrangement.update_columns(status: :completed)
end
end
end

View File

@ -29,5 +29,5 @@
class Seat < ApplicationRecord
acts_as_tenant :wedding
belongs_to :guest
belongs_to :table_arrangement
belongs_to :tables_arrangement
end

View File

@ -10,6 +10,8 @@
# digest :uuid not null
# discomfort :integer
# name :string not null
# progress :float default(0.0), not null
# status :string default("complete"), not null
# created_at :datetime not null
# updated_at :datetime not null
# wedding_id :uuid not null

View File

@ -12,13 +12,14 @@ module Tables
end
end
attr_accessor :tables, :min_per_table, :max_per_table, :hierarchy
attr_accessor :tables, :min_per_table, :max_per_table, :hierarchy, :tables_arrangement_id
def initialize(min_per_table:, max_per_table:, hierarchy: AffinityGroupsHierarchy.new)
def initialize(min_per_table:, max_per_table:, tables_arrangement_id:, hierarchy: AffinityGroupsHierarchy.new)
@min_per_table = min_per_table
@max_per_table = max_per_table
@hierarchy = hierarchy
@tables = []
@tables_arrangement_id = tables_arrangement_id
end
def random_distribution(people, random: Random.new)
@ -42,15 +43,23 @@ module Tables
end
def deep_dup
self.class.new(min_per_table: @min_per_table, max_per_table: @max_per_table,
hierarchy: @hierarchy).tap do |new_distribution|
self.class.new(
min_per_table: @min_per_table,
max_per_table: @max_per_table,
hierarchy: @hierarchy,
tables_arrangement_id: @tables_arrangement_id
).tap do |new_distribution|
new_distribution.tables = @tables.map(&:dup)
end
end
def save!
ActiveRecord::Base.transaction do
arrangement = TablesArrangement.create!
arrangement = TablesArrangement.find(tables_arrangement_id)
self.tables_arrangement_id = arrangement.id
arrangement.seats.delete_all
records_to_store = []

View File

@ -5,6 +5,7 @@
module VNS
class Engine
PERTURBATION_SIZES = [1, 1, 1, 2, 2, 3].freeze
ITERATIONS = 50
class << self
def sequence(elements)
elements = elements.to_a
@ -12,6 +13,10 @@ module VNS
end
end
def initialize
@perturbations = Set.new
end
def target_function(&function)
@target_function = function
end
@ -22,36 +27,45 @@ module VNS
end
def add_perturbation(klass)
@perturbations ||= Set.new
@perturbations << klass
end
def notify_progress(&block)
@progress_notifier = block
end
def on_better_solution(&block)
@better_solution_notifier = block
end
attr_writer :initial_solution
def run
raise 'No target function defined' unless @target_function
raise 'No optimizations defined' unless @optimizations
raise 'No initial solution defined' unless @initial_solution
@perturbations ||= Set.new
check_preconditions!
@current_solution = @initial_solution
@best_score = @target_function.call(@current_solution)
run_all_optimizations
@progress_notifier&.call(Rational(1, ITERATIONS + 1))
best_solution = @current_solution
50.times do
(1..ITERATIONS).each do |iteration|
@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
@progress_notifier&.call(Rational(iteration + 1, ITERATIONS + 1))
next unless best_solution.discomfort > @current_solution.discomfort
best_solution = @current_solution
@better_solution_notifier&.call(best_solution)
Rails.logger.debug do
"Found better solution after perturbation optimization: #{@current_solution.discomfort}"
end
@ -62,6 +76,12 @@ module VNS
private
def check_preconditions!
raise 'No target function defined' unless @target_function
raise 'No optimizations defined' unless @optimizations
raise 'No initial solution defined' unless @initial_solution
end
def run_all_optimizations
self.class.sequence(@optimizations).each do |optimization|
optimize(optimization)

View File

@ -3,4 +3,5 @@
require_relative "../config/environment"
require "solid_queue/cli"
SolidQueue.logger = ActiveSupport::Logger.new($stdout)
SolidQueue::Cli.start(ARGV)

View File

@ -0,0 +1,5 @@
class AddStatusColumnToTablesArrangements < ActiveRecord::Migration[8.0]
def change
add_column :tables_arrangements, :status, :string, default: :complete, null: false
end
end

View File

@ -0,0 +1,5 @@
class AddProgressToTablesArrangements < ActiveRecord::Migration[8.0]
def change
add_column :tables_arrangements, :progress, :float, default: 0, null: false
end
end

4
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[8.0].define(version: 2025_06_08_181054) do
ActiveRecord::Schema[8.0].define(version: 2025_09_08_145119) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -216,6 +216,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_08_181054) do
t.string "name", null: false
t.uuid "wedding_id", null: false
t.uuid "digest", default: -> { "gen_random_uuid()" }, null: false
t.string "status", default: "complete", null: false
t.float "progress", default: 0.0, null: false
t.index ["wedding_id"], name: "index_tables_arrangements_on_wedding_id"
end

View File

@ -86,7 +86,9 @@ ActsAsTenant.with_tenant(wedding) do
# TODO: Clean up invitations with no guests
ActiveJob.perform_all_later(3.times.map { TableSimulatorJob.new(wedding.id) })
3.times { TablesArrangement.create! }
.map { |arrangement| TableSimulatorJob.new(wedding.id, arrangement.id) }
.then { |jobs| ActiveJob.perform_all_later }
"red".dup.paint.palette.triad(as: :hex).zip(Group.roots).each { |(color, group)| group.update!(color: color.paint.desaturate(40)) }

View File

@ -21,6 +21,14 @@ namespace :vns do
engine.target_function(&:discomfort)
engine.notify_progress do |current_progress|
Rails.logger.info "Progress: #{(current_progress * 100.0).round(2)}%"
end
engine.on_better_solution do |better_solution|
Rails.logger.info "New best solution found with discomfort: #{better_solution.discomfort}"
end
solution = Rails.benchmark('VNS Benchmarking') { engine.run }
Rails.logger.info "Best solution found with discomfort: #{solution.discomfort}"

View File

@ -12,6 +12,11 @@ server {
proxy_set_header Host $http_host;
}
location /jobs/ {
proxy_pass http://backend:3000/jobs/;
proxy_set_header Host $http_host;
}
location /captcha/v2/media/ {
proxy_pass http://libre-captcha:8888/v2/media/;
proxy_set_header Host $http_host;

View File

@ -19,7 +19,8 @@ RSpec.describe 'tables_arrangements' do
id: { type: :string, format: :uuid },
name: { type: :string },
discomfort: { type: :integer },
valid: { type: :boolean }
valid: { type: :boolean },
status: { type: :string, enum: %w[complete in_progress] }
}
}
xit

View File

@ -6,8 +6,43 @@ require 'rails_helper'
module Tables
RSpec.describe Distribution do
let(:tables_arrangement) { TablesArrangement.create! }
around do |example|
ActsAsTenant.with_tenant(create(:wedding)) do
example.run
end
end
describe '#save!' do
let(:people) { create_list(:guest, 2, status: :invited) }
let(:distribution) do
described_class.new(min_per_table: 5, max_per_table: 10, tables_arrangement_id: tables_arrangement.id)
.tap { |d| d.random_distribution(people) }
end
context 'when tables_arrangement_id is nil' do
it { expect { distribution.save! }.to change(TablesArrangement, :count).by(1) }
it { expect { distribution.save! }.to change(Seat, :count).by(2) }
end
context 'when tables_arrangement_id is set' do
before do
existing_arrangement = TablesArrangement.create!
existing_arrangement.seats.create!(guest: people.first, table_number: 1)
distribution.tables_arrangement_id = existing_arrangement.id
end
it { expect { distribution.save! }.not_to(change(TablesArrangement, :count)) }
it { expect { distribution.save! }.to change(Seat, :count).by(1) }
end
end
describe '#random_distribution' do
subject(:distribution) { described_class.new(min_per_table: 5, max_per_table: 10) }
subject(:distribution) do
described_class.new(min_per_table: 5, max_per_table: 10, tables_arrangement_id: tables_arrangement.id)
end
context 'when there are fewer people than the minimum per table' do
it 'creates one table' do

View File

@ -17,7 +17,7 @@ module Tables
context 'when there are two tables with two people each' do
let(:initial_solution) do
Distribution.new(min_per_table: 2, max_per_table: 2).tap do |distribution|
Distribution.new(min_per_table: 2, max_per_table: 2, tables_arrangement_id: nil).tap do |distribution|
distribution.tables << Set[:a, :b].to_table
distribution.tables << Set[:c, :d].to_table
end
@ -35,7 +35,7 @@ module Tables
context 'when there are two tables with three people each' do
let(:initial_solution) do
Distribution.new(min_per_table: 3, max_per_table: 3).tap do |distribution|
Distribution.new(min_per_table: 3, max_per_table: 3, tables_arrangement_id: nil).tap do |distribution|
distribution.tables << Set[:a, :b, :c].to_table
distribution.tables << Set[:d, :e, :f].to_table
end

View File

@ -17,7 +17,7 @@ module Tables
context 'when there are two tables with two people each' do
let(:initial_solution) do
Distribution.new(min_per_table: 2, max_per_table: 2).tap do |distribution|
Distribution.new(min_per_table: 2, max_per_table: 2, tables_arrangement_id: nil).tap do |distribution|
distribution.tables << Set[:a, :b].to_table
distribution.tables << Set[:c, :d].to_table
end
@ -35,7 +35,7 @@ module Tables
context 'when there are two tables with three people each' do
let(:initial_solution) do
Distribution.new(min_per_table: 3, max_per_table: 3).tap do |distribution|
Distribution.new(min_per_table: 3, max_per_table: 3, tables_arrangement_id: nil).tap do |distribution|
distribution.tables << Set[:a, :b, :c].to_table
distribution.tables << Set[:d, :e, :f].to_table
end
@ -58,7 +58,7 @@ module Tables
context 'when there are three tables with two people each' do
let(:initial_solution) do
Distribution.new(min_per_table: 2, max_per_table: 2).tap do |distribution|
Distribution.new(min_per_table: 2, max_per_table: 2, tables_arrangement_id: nil).tap do |distribution|
distribution.tables << Set[:a, :b].to_table
distribution.tables << Set[:c, :d].to_table
distribution.tables << Set[:e, :f].to_table

View File

@ -8,7 +8,7 @@ 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.new(min_per_table: 3, max_per_table: 3, tables_arrangement_id: nil).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