Support multiple room sizes via room_groups table

This commit is contained in:
Ian Gulliver
2026-02-16 13:49:15 -08:00
parent 037bef6bc8
commit 1f16aa1ff8
7 changed files with 218 additions and 61 deletions

View File

@@ -15,10 +15,15 @@ import (
"rooms/solver"
)
type roomGroupData struct {
Size int `json:"size"`
Count int `json:"count"`
}
type tripData struct {
RoomSize int `json:"room_size"`
PreferNotMultiple int `json:"prefer_not_multiple"`
NoPreferCost int `json:"no_prefer_cost"`
RoomGroups []roomGroupData `json:"room_groups"`
}
type studentData struct {
@@ -189,7 +194,18 @@ func main() {
})
}
fmt.Printf("Students: %d, Room size: %d, Constraints: %d\n", n, trip.RoomSize, len(constraints))
var roomSizes []int
for _, rg := range trip.RoomGroups {
for range rg.Count {
roomSizes = append(roomSizes, rg.Size)
}
}
if len(roomSizes) == 0 {
fmt.Fprintf(os.Stderr, "no room_groups in trip data\n")
os.Exit(1)
}
fmt.Printf("Students: %d, Room sizes: %v, Constraints: %d\n", n, roomSizes, len(constraints))
fmt.Printf("Prefer Not multiple: %d, No Prefer cost: %d\n", trip.PreferNotMultiple, trip.NoPreferCost)
fmt.Printf("Runs per config: %d\n\n", *runs)
@@ -207,7 +223,7 @@ func main() {
for run := range *runs {
rng := rand.New(rand.NewSource(int64(run * 31337)))
start := time.Now()
sols := solver.SolveFast(n, trip.RoomSize, trip.PreferNotMultiple, trip.NoPreferCost, constraints, params, rng)
sols := solver.SolveFast(n, roomSizes, trip.PreferNotMultiple, trip.NoPreferCost, constraints, params, rng)
elapsed := time.Since(start)
if len(sols) > 0 {
var assignments [][]int

View File

@@ -1,6 +1,7 @@
DROP TABLE IF EXISTS roommate_constraints;
DROP TABLE IF EXISTS parents;
DROP TABLE IF EXISTS students;
DROP TABLE IF EXISTS room_groups;
DROP TABLE IF EXISTS trip_admins;
DROP TABLE IF EXISTS trips;
DROP TYPE IF EXISTS constraint_level;

154
main.go
View File

@@ -87,6 +87,9 @@ func main() {
http.HandleFunc("GET /api/trips/{tripID}/constraints", handleListConstraints(db))
http.HandleFunc("POST /api/trips/{tripID}/constraints", handleCreateConstraint(db))
http.HandleFunc("DELETE /api/trips/{tripID}/constraints/{constraintID}", handleDeleteConstraint(db))
http.HandleFunc("GET /api/trips/{tripID}/room-groups", handleListRoomGroups(db))
http.HandleFunc("POST /api/trips/{tripID}/room-groups", handleCreateRoomGroup(db))
http.HandleFunc("DELETE /api/trips/{tripID}/room-groups/{groupID}", handleDeleteRoomGroup(db))
http.HandleFunc("POST /api/trips/{tripID}/solve", handleSolve(db))
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(); err != nil {
@@ -295,13 +298,13 @@ func handleListTrips(db *sql.DB) http.HandlerFunc {
return
}
rows, err := db.Query(`
SELECT t.id, t.name, t.room_size, t.prefer_not_multiple, t.no_prefer_cost, COALESCE(
SELECT t.id, t.name, t.prefer_not_multiple, t.no_prefer_cost, COALESCE(
json_agg(json_build_object('id', ta.id, 'email', ta.email)) FILTER (WHERE ta.id IS NOT NULL),
'[]'
)
FROM trips t
LEFT JOIN trip_admins ta ON ta.trip_id = t.id
GROUP BY t.id, t.name, t.room_size, t.prefer_not_multiple, t.no_prefer_cost
GROUP BY t.id, t.name, t.prefer_not_multiple, t.no_prefer_cost
ORDER BY t.id`)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -316,7 +319,6 @@ func handleListTrips(db *sql.DB) http.HandlerFunc {
type trip struct {
ID int64 `json:"id"`
Name string `json:"name"`
RoomSize int `json:"room_size"`
PreferNotMultiple int `json:"prefer_not_multiple"`
NoPreferCost int `json:"no_prefer_cost"`
Admins []tripAdmin `json:"admins"`
@@ -326,7 +328,7 @@ func handleListTrips(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var t trip
var adminsJSON string
if err := rows.Scan(&t.ID, &t.Name, &t.RoomSize, &t.PreferNotMultiple, &t.NoPreferCost, &adminsJSON); err != nil {
if err := rows.Scan(&t.ID, &t.Name, &t.PreferNotMultiple, &t.NoPreferCost, &adminsJSON); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -470,14 +472,14 @@ func handleGetTrip(db *sql.DB) http.HandlerFunc {
return
}
var name string
var roomSize, preferNotMultiple, noPreferCost int
err := db.QueryRow("SELECT name, room_size, prefer_not_multiple, no_prefer_cost FROM trips WHERE id = $1", tripID).Scan(&name, &roomSize, &preferNotMultiple, &noPreferCost)
var preferNotMultiple, noPreferCost int
err := db.QueryRow("SELECT name, prefer_not_multiple, no_prefer_cost FROM trips WHERE id = $1", tripID).Scan(&name, &preferNotMultiple, &noPreferCost)
if err != nil {
http.Error(w, "trip not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"id": tripID, "name": name, "room_size": roomSize, "prefer_not_multiple": preferNotMultiple, "no_prefer_cost": noPreferCost})
json.NewEncoder(w).Encode(map[string]any{"id": tripID, "name": name, "prefer_not_multiple": preferNotMultiple, "no_prefer_cost": noPreferCost})
}
}
@@ -672,7 +674,6 @@ func handleUpdateTrip(db *sql.DB) http.HandlerFunc {
return
}
var body struct {
RoomSize *int `json:"room_size"`
PreferNotMultiple *int `json:"prefer_not_multiple"`
NoPreferCost *int `json:"no_prefer_cost"`
}
@@ -680,12 +681,6 @@ func handleUpdateTrip(db *sql.DB) http.HandlerFunc {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if body.RoomSize != nil {
if *body.RoomSize < 1 {
http.Error(w, "room_size must be at least 1", http.StatusBadRequest)
return
}
}
if body.PreferNotMultiple != nil {
if *body.PreferNotMultiple < 1 {
http.Error(w, "prefer_not_multiple must be at least 1", http.StatusBadRequest)
@@ -698,12 +693,6 @@ func handleUpdateTrip(db *sql.DB) http.HandlerFunc {
return
}
}
if body.RoomSize != nil {
if _, err := db.Exec("UPDATE trips SET room_size = $1 WHERE id = $2", *body.RoomSize, tripID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if body.PreferNotMultiple != nil {
if _, err := db.Exec("UPDATE trips SET prefer_not_multiple = $1 WHERE id = $2", *body.PreferNotMultiple, tripID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -993,19 +982,21 @@ func handleListConstraints(db *sql.DB) http.HandlerFunc {
hardConflicts = append(hardConflicts, chain)
}
var roomSize int
db.QueryRow("SELECT room_size FROM trips WHERE id = $1", tripID).Scan(&roomSize)
var maxRoomSize int
db.QueryRow("SELECT COALESCE(MAX(size), 0) FROM room_groups WHERE trip_id = $1", tripID).Scan(&maxRoomSize)
mustGroups := map[int64][]string{}
for _, id := range studentIDs {
root := ufFind(id)
mustGroups[root] = append(mustGroups[root], studentName[id])
}
if maxRoomSize > 0 {
for _, members := range mustGroups {
if len(members) > roomSize {
if len(members) > maxRoomSize {
oversizedGroups = append(oversizedGroups, members)
}
}
}
}
if mismatches == nil {
mismatches = []mismatchEntry{}
}
@@ -1119,6 +1110,93 @@ func handleDeleteConstraint(db *sql.DB) http.HandlerFunc {
}
}
func handleListRoomGroups(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, tripID, ok := requireTripAdmin(db, w, r)
if !ok {
return
}
rows, err := db.Query("SELECT id, size, count FROM room_groups WHERE trip_id = $1 ORDER BY id", tripID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
type roomGroup struct {
ID int64 `json:"id"`
Size int `json:"size"`
Count int `json:"count"`
}
var groups []roomGroup
for rows.Next() {
var g roomGroup
if err := rows.Scan(&g.ID, &g.Size, &g.Count); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
groups = append(groups, g)
}
if groups == nil {
groups = []roomGroup{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(groups)
}
}
func handleCreateRoomGroup(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, tripID, ok := requireTripAdmin(db, w, r)
if !ok {
return
}
var body struct {
Size int `json:"size"`
Count int `json:"count"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Size < 1 || body.Count < 1 {
http.Error(w, "size and count must be at least 1", http.StatusBadRequest)
return
}
var id int64
err := db.QueryRow("INSERT INTO room_groups (trip_id, size, count) VALUES ($1, $2, $3) RETURNING id", tripID, body.Size, body.Count).Scan(&id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"id": id, "size": body.Size, "count": body.Count})
}
}
func handleDeleteRoomGroup(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, tripID, ok := requireTripAdmin(db, w, r)
if !ok {
return
}
groupID, err := strconv.ParseInt(r.PathValue("groupID"), 10, 64)
if err != nil {
http.Error(w, "invalid group ID", http.StatusBadRequest)
return
}
result, err := db.Exec("DELETE FROM room_groups WHERE id = $1 AND trip_id = $2", groupID, tripID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if n, _ := result.RowsAffected(); n == 0 {
http.Error(w, "room group not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func handleSolve(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, tripID, ok := requireTripAdmin(db, w, r)
@@ -1126,13 +1204,35 @@ func handleSolve(db *sql.DB) http.HandlerFunc {
return
}
var roomSize, pnMultiple, npCost int
err := db.QueryRow("SELECT room_size, prefer_not_multiple, no_prefer_cost FROM trips WHERE id = $1", tripID).Scan(&roomSize, &pnMultiple, &npCost)
var pnMultiple, npCost int
err := db.QueryRow("SELECT prefer_not_multiple, no_prefer_cost FROM trips WHERE id = $1", tripID).Scan(&pnMultiple, &npCost)
if err != nil {
http.Error(w, "trip not found", http.StatusNotFound)
return
}
rgRows, err := db.Query("SELECT size, count FROM room_groups WHERE trip_id = $1 ORDER BY id", tripID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rgRows.Close()
var roomSizes []int
for rgRows.Next() {
var size, count int
if err := rgRows.Scan(&size, &count); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for range count {
roomSizes = append(roomSizes, size)
}
}
if len(roomSizes) == 0 {
http.Error(w, "no room groups configured", http.StatusBadRequest)
return
}
rows, err := db.Query("SELECT id, name FROM students WHERE trip_id = $1 ORDER BY id", tripID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -1219,14 +1319,14 @@ func handleSolve(db *sql.DB) http.HandlerFunc {
}
rng := rand.New(rand.NewSource(42))
solutions := solver.SolveFast(n, roomSize, pnMultiple, npCost, constraints, solver.DefaultParams, rng)
solutions := solver.SolveFast(n, roomSizes, pnMultiple, npCost, constraints, solver.DefaultParams, rng)
if solutions == nil {
http.Error(w, "hard conflicts exist, resolve before solving", http.StatusBadRequest)
return
}
numRooms := (n + roomSize - 1) / roomSize
numRooms := len(roomSizes)
type roomMember struct {
ID int64 `json:"id"`

View File

@@ -11,11 +11,19 @@ END $$;
CREATE TABLE IF NOT EXISTS trips (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
room_size INTEGER NOT NULL DEFAULT 2,
prefer_not_multiple INTEGER NOT NULL DEFAULT 5,
no_prefer_cost INTEGER NOT NULL DEFAULT 10
);
CREATE TABLE IF NOT EXISTS room_groups (
id BIGSERIAL PRIMARY KEY,
trip_id BIGINT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
size INTEGER NOT NULL,
count INTEGER NOT NULL,
CHECK(size >= 1),
CHECK(count >= 1)
);
CREATE TABLE IF NOT EXISTS trip_admins (
id BIGSERIAL PRIMARY KEY,
trip_id BIGINT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,

View File

@@ -58,7 +58,7 @@ func normalizeKey(a []int) string {
type solverState struct {
n int
roomSize int
roomSizes []int
numRooms int
pnMultiple int
npCost int
@@ -77,11 +77,11 @@ type solverState struct {
mustApartFor [][]int
}
func newSolverState(n, roomSize, pnMultiple, npCost int, constraints []Constraint) *solverState {
func newSolverState(n int, roomSizes []int, pnMultiple, npCost int, constraints []Constraint) *solverState {
s := &solverState{
n: n,
roomSize: roomSize,
numRooms: (n + roomSize - 1) / roomSize,
roomSizes: roomSizes,
numRooms: len(roomSizes),
pnMultiple: pnMultiple,
npCost: npCost,
constraints: constraints,
@@ -235,7 +235,6 @@ func (s *solverState) feasibleForGroup(assignment []int, groupRoot int, room int
func (s *solverState) fastHillClimb(assignment []int) int {
n := s.n
roomSize := s.roomSize
numRooms := s.numRooms
roomCounts := make([]int, numRooms)
@@ -386,7 +385,7 @@ func (s *solverState) fastHillClimb(assignment []int) int {
if room == gRoom {
continue
}
if roomCounts[room]+len(grp) > roomSize {
if roomCounts[room]+len(grp) > s.roomSizes[room] {
continue
}
if !s.feasibleForGroup(assignment, gRoot, room) {
@@ -410,7 +409,7 @@ func (s *solverState) fastHillClimb(assignment []int) int {
}
newGRoom := roomCounts[gRoom] - len(grp) + len(grp2)
newG2Room := roomCounts[g2Room] - len(grp2) + len(grp)
if newGRoom > roomSize || newG2Room > roomSize {
if newGRoom > s.roomSizes[gRoom] || newG2Room > s.roomSizes[g2Room] {
continue
}
if !s.feasibleForGroup(assignment, gRoot, g2Room) {
@@ -456,9 +455,7 @@ func (s *solverState) fastHillClimb(assignment []int) int {
func (s *solverState) initialPlacement(assignment []int) bool {
roomCap := make([]int, s.numRooms)
for i := range roomCap {
roomCap[i] = s.roomSize
}
copy(roomCap, s.roomSizes)
var placeGroups func(gi int) bool
placeGroups = func(gi int) bool {
@@ -517,9 +514,7 @@ func (s *solverState) initialPlacement(assignment []int) bool {
func (s *solverState) randomPlacement(assignment []int, rng *rand.Rand) bool {
roomCap := make([]int, s.numRooms)
for i := range roomCap {
roomCap[i] = s.roomSize
}
copy(roomCap, s.roomSizes)
perm := rng.Perm(len(s.groupList))
for _, pi := range perm {
grp := s.groupList[pi]
@@ -597,12 +592,12 @@ func (t *solutionTracker) add(a []int, s int) {
}
}
func SolveFast(n, roomSize, pnMultiple, npCost int, constraints []Constraint, params Params, rng *rand.Rand) []Solution {
func SolveFast(n int, roomSizes []int, pnMultiple, npCost int, constraints []Constraint, params Params, rng *rand.Rand) []Solution {
if n == 0 {
return nil
}
st := newSolverState(n, roomSize, pnMultiple, npCost, constraints)
st := newSolverState(n, roomSizes, pnMultiple, npCost, constraints)
if st.hasHardConflict() {
return nil
}
@@ -637,8 +632,8 @@ func SolveFast(n, roomSize, pnMultiple, npCost int, constraints []Constraint, pa
for _, room := range a {
rc[room]++
}
for _, cnt := range rc {
if cnt > roomSize {
for room, cnt := range rc {
if cnt > st.roomSizes[room] {
return false
}
}
@@ -657,7 +652,7 @@ func SolveFast(n, roomSize, pnMultiple, npCost int, constraints []Constraint, pa
if room == oldRoom {
continue
}
if roomCount(assignment, room)+len(grp) > roomSize {
if roomCount(assignment, room)+len(grp) > st.roomSizes[room] {
continue
}
for _, m := range grp {

View File

@@ -95,7 +95,15 @@
<h2 id="trip-name"></h2>
<div id="admin-view" style="display: none;">
<div id="trip-settings">
<label>Students per room: <input id="room-size" type="number" min="1"></label>
<div id="room-groups">
<div class="tags" id="room-group-tags"></div>
<div style="display: flex; gap: 0.3rem; align-items: center; flex-wrap: wrap;">
<wa-input id="new-rg-count" type="number" min="1" placeholder="Count" size="small" style="width: 5rem;"></wa-input>
<span>rooms of</span>
<wa-input id="new-rg-size" type="number" min="1" placeholder="Size" size="small" style="width: 5rem;"></wa-input>
<wa-button id="add-rg-btn" size="small">Add</wa-button>
</div>
</div>
<label>Prefer Not cost: <input id="pn-multiple" type="number" min="1"></label>
<label>No Prefer cost: <input id="np-cost" type="number" min="0"></label>
</div>

View File

@@ -30,13 +30,41 @@ if (me.role !== 'admin') {
await (async () => {
document.getElementById('admin-view').style.display = 'block';
document.getElementById('room-size').value = trip.room_size;
document.getElementById('pn-multiple').value = trip.prefer_not_multiple;
document.getElementById('np-cost').value = trip.no_prefer_cost;
document.getElementById('room-size').addEventListener('change', async () => {
const size = parseInt(document.getElementById('room-size').value);
if (size >= 1) await api('PATCH', '/api/trips/' + tripID, { room_size: size });
let roomGroups = [];
async function loadRoomGroups() {
roomGroups = await api('GET', '/api/trips/' + tripID + '/room-groups');
const tags = document.getElementById('room-group-tags');
tags.innerHTML = '';
for (const rg of roomGroups) {
const tag = document.createElement('wa-tag');
tag.size = 'small';
tag.setAttribute('with-remove', '');
tag.textContent = rg.count + ' \u00d7 ' + rg.size + '-person';
tag.addEventListener('wa-remove', async () => {
await api('DELETE', '/api/trips/' + tripID + '/room-groups/' + rg.id);
loadRoomGroups();
});
tags.appendChild(tag);
}
}
await loadRoomGroups();
document.getElementById('add-rg-btn').addEventListener('click', async () => {
const sizeInput = document.getElementById('new-rg-size');
const countInput = document.getElementById('new-rg-count');
const size = parseInt((sizeInput.value || '').trim());
const count = parseInt((countInput.value || '').trim());
if (!size || size < 1 || !count || count < 1) return;
await api('POST', '/api/trips/' + tripID + '/room-groups', { size, count });
sizeInput.value = '';
countInput.value = '';
loadRoomGroups();
});
document.getElementById('pn-multiple').addEventListener('change', async () => {
const val = parseInt(document.getElementById('pn-multiple').value);
if (val >= 1) await api('PATCH', '/api/trips/' + tripID, { prefer_not_multiple: val });
@@ -158,7 +186,8 @@ async function loadStudents() {
const div = document.createElement('div');
div.className = 'conflict-row';
div.appendChild(kindSpan('must'));
div.appendChild(document.createTextNode(' group too large (' + members.length + ' for room size ' + trip.room_size + '): ' + members.join(', ')));
const maxSize = roomGroups.length > 0 ? Math.max(...roomGroups.map(g => g.size)) : 0;
div.appendChild(document.createTextNode(' group too large (' + members.length + ' for max room size ' + maxSize + '): ' + members.join(', ')));
det.appendChild(div);
}
hardConflictsEl.appendChild(det);