From 5350a9ab1f5f09e363360c0af25015e02b3f44da Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Mon, 16 Feb 2026 11:17:24 -0800 Subject: [PATCH] Return all top-scoring solver configurations and display as tabs --- main.go | 105 +++++++++++++++++++++++++++++++------------- static/trip.js | 116 +++++++++++++++++++++++++++++-------------------- 2 files changed, 145 insertions(+), 76 deletions(-) diff --git a/main.go b/main.go index d73d742..e311dde 100644 --- a/main.go +++ b/main.go @@ -1152,7 +1152,7 @@ func handleSolve(db *sql.DB) http.HandlerFunc { if len(studentIDs) == 0 { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"rooms": []any{}, "score": 0}) + json.NewEncoder(w).Encode(map[string]any{"solutions": []any{}}) return } @@ -1360,9 +1360,47 @@ func handleSolve(db *sql.DB) http.HandlerFunc { initialAssignment := make([]int, n) copy(initialAssignment, assignment) - bestAssignment := make([]int, n) - copy(bestAssignment, assignment) bestScore := score(assignment) + var bestSolutions [][]int + seen := map[string]bool{} + normalizeKey := func(a []int) string { + rm := map[int][]int{} + for i, room := range a { + rm[room] = append(rm[room], i) + } + var gs [][]int + for _, members := range rm { + slices.Sort(members) + gs = append(gs, members) + } + slices.SortFunc(gs, func(a, b []int) int { return a[0] - b[0] }) + var buf strings.Builder + for _, g := range gs { + for i, m := range g { + if i > 0 { + buf.WriteByte(',') + } + buf.WriteString(strconv.Itoa(m)) + } + buf.WriteByte(';') + } + return buf.String() + } + addSolution := func(a []int, s int) { + if s > bestScore { + bestScore = s + bestSolutions = nil + seen = map[string]bool{} + } + if s == bestScore { + key := normalizeKey(a) + if !seen[key] { + seen[key] = true + bestSolutions = append(bestSolutions, slices.Clone(a)) + } + } + } + addSolution(assignment, bestScore) roomCount := func(a []int, room int) int { c := 0 @@ -1498,50 +1536,57 @@ func handleSolve(db *sql.DB) http.HandlerFunc { } copy(assignment, initialAssignment) - s := hillClimb(assignment) - if s > bestScore { - bestScore = s - copy(bestAssignment, assignment) - } + addSolution(assignment, hillClimb(assignment)) for range 30 { if randomPlacement() { - s := hillClimb(assignment) - if s > bestScore { - bestScore = s - copy(bestAssignment, assignment) - } + addSolution(assignment, hillClimb(assignment)) } } for range 200 { - perturb(bestAssignment, 2+rand.Intn(3)) - s := hillClimb(assignment) - if s > bestScore { - bestScore = s - copy(bestAssignment, assignment) - } + src := bestSolutions[rand.Intn(len(bestSolutions))] + perturb(src, 2+rand.Intn(3)) + addSolution(assignment, hillClimb(assignment)) } type roomMember struct { ID int64 `json:"id"` Name string `json:"name"` } - roomMap := map[int][]roomMember{} - for i, room := range bestAssignment { - sid := studentIDs[i] - roomMap[room] = append(roomMap[room], roomMember{ID: sid, Name: studentName[sid]}) + type solutionResult struct { + Rooms [][]roomMember `json:"rooms"` + Score int `json:"score"` } - var rooms [][]roomMember - for room := range numRooms { - if members, ok := roomMap[room]; ok { - slices.SortFunc(members, func(a, b roomMember) int { return strings.Compare(a.Name, b.Name) }) - rooms = append(rooms, members) + var results []solutionResult + for _, sol := range bestSolutions { + roomMap := map[int][]roomMember{} + for i, room := range sol { + sid := studentIDs[i] + roomMap[room] = append(roomMap[room], roomMember{ID: sid, Name: studentName[sid]}) } + var rooms [][]roomMember + for room := range numRooms { + if members, ok := roomMap[room]; ok { + slices.SortFunc(members, func(a, b roomMember) int { return strings.Compare(a.Name, b.Name) }) + rooms = append(rooms, members) + } + } + slices.SortFunc(rooms, func(a, b []roomMember) int { return strings.Compare(a[0].Name, b[0].Name) }) + results = append(results, solutionResult{Rooms: rooms, Score: bestScore}) } - slices.SortFunc(rooms, func(a, b []roomMember) int { return strings.Compare(a[0].Name, b[0].Name) }) + slices.SortFunc(results, func(a, b solutionResult) int { + for i := range min(len(a.Rooms), len(b.Rooms)) { + for j := range min(len(a.Rooms[i]), len(b.Rooms[i])) { + if c := strings.Compare(a.Rooms[i][j].Name, b.Rooms[i][j].Name); c != 0 { + return c + } + } + } + return 0 + }) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"rooms": rooms, "score": bestScore}) + json.NewEncoder(w).Encode(map[string]any{"solutions": results}) } } diff --git a/static/trip.js b/static/trip.js index 96f0157..232b56d 100644 --- a/static/trip.js +++ b/static/trip.js @@ -422,60 +422,84 @@ document.getElementById('solve-btn').addEventListener('click', async () => { const result = await api('POST', '/api/trips/' + tripID + '/solve'); const container = document.getElementById('solver-results'); container.innerHTML = ''; - for (let i = 0; i < result.rooms.length; i++) { - const card = document.createElement('wa-card'); - card.className = 'room-card'; - const label = document.createElement('div'); - label.className = 'room-label'; - label.textContent = 'Room ' + (i + 1); - card.appendChild(label); - const tags = document.createElement('div'); - tags.className = 'tags'; - const roomIDs = result.rooms[i].map(m => m.id); - const violations = []; - for (const a of result.rooms[i]) { - for (const b of result.rooms[i]) { - if (a.id === b.id) continue; - const eff = lastOveralls[a.id]?.[b.id]; - if (eff && eff.kind === 'prefer_not') { - violations.push({ from: a.name, to: b.name }); + + const renderSolution = (sol, parent) => { + for (let i = 0; i < sol.rooms.length; i++) { + const card = document.createElement('wa-card'); + card.className = 'room-card'; + const label = document.createElement('div'); + label.className = 'room-label'; + label.textContent = 'Room ' + (i + 1); + card.appendChild(label); + const tags = document.createElement('div'); + tags.className = 'tags'; + const roomIDs = sol.rooms[i].map(m => m.id); + const violations = []; + for (const a of sol.rooms[i]) { + for (const b of sol.rooms[i]) { + if (a.id === b.id) continue; + const eff = lastOveralls[a.id]?.[b.id]; + if (eff && eff.kind === 'prefer_not') { + violations.push({ from: a.name, to: b.name }); + } } } + for (const member of sol.rooms[i]) { + const tag = document.createElement('wa-tag'); + tag.size = 'small'; + tag.style.cursor = 'pointer'; + const hasViolation = violations.some(v => v.from === member.name || v.to === member.name); + const hasPrefers = Object.values(lastOveralls[member.id] || {}).some(e => e.kind === 'prefer'); + const gotPrefer = hasPrefers && roomIDs.some(rid => rid !== member.id && lastOveralls[member.id]?.[rid]?.kind === 'prefer'); + if (hasViolation) tag.variant = 'danger'; + else if (hasPrefers && !gotPrefer) tag.variant = 'warning'; + tag.textContent = member.name; + tag.addEventListener('click', () => { + const studentCard = document.querySelector('[data-student-id="' + member.id + '"]'); + if (!studentCard) return; + const cDet = [...studentCard.querySelectorAll('wa-details')].find(d => d.summary === 'Constraints'); + if (cDet) cDet.open = true; + studentCard.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + tags.appendChild(tag); + } + if (violations.length > 0) { + const warn = document.createElement('div'); + warn.style.fontSize = '0.75rem'; + warn.style.color = 'var(--wa-color-warning-50)'; + warn.textContent = violations.map(v => v.from + ' \u2192 ' + v.to).join(', '); + card.appendChild(tags); + card.appendChild(warn); + } else { + card.appendChild(tags); + } + parent.appendChild(card); } - for (const member of result.rooms[i]) { - const tag = document.createElement('wa-tag'); - tag.size = 'small'; - tag.style.cursor = 'pointer'; - const hasViolation = violations.some(v => v.from === member.name || v.to === member.name); - const hasPrefers = Object.values(lastOveralls[member.id] || {}).some(e => e.kind === 'prefer'); - const gotPrefer = hasPrefers && roomIDs.some(rid => rid !== member.id && lastOveralls[member.id]?.[rid]?.kind === 'prefer'); - if (hasViolation) tag.variant = 'danger'; - else if (hasPrefers && !gotPrefer) tag.variant = 'warning'; - tag.textContent = member.name; - tag.addEventListener('click', () => { - const card = document.querySelector('[data-student-id="' + member.id + '"]'); - if (!card) return; - const cDet = [...card.querySelectorAll('wa-details')].find(d => d.summary === 'Constraints'); - if (cDet) cDet.open = true; - card.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); - tags.appendChild(tag); + }; + + const solutions = result.solutions; + if (solutions.length === 1) { + renderSolution(solutions[0], container); + } else if (solutions.length > 1) { + const tabGroup = document.createElement('wa-tab-group'); + for (let si = 0; si < solutions.length; si++) { + const tab = document.createElement('wa-tab'); + tab.slot = 'nav'; + tab.panel = 'sol-' + si; + tab.textContent = 'Option ' + (si + 1); + tabGroup.appendChild(tab); } - if (violations.length > 0) { - const warn = document.createElement('div'); - warn.style.fontSize = '0.75rem'; - warn.style.color = 'var(--wa-color-warning-50)'; - warn.textContent = violations.map(v => v.from + ' \u2192 ' + v.to).join(', '); - card.appendChild(tags); - card.appendChild(warn); - } else { - card.appendChild(tags); + for (let si = 0; si < solutions.length; si++) { + const panel = document.createElement('wa-tab-panel'); + panel.name = 'sol-' + si; + renderSolution(solutions[si], panel); + tabGroup.appendChild(panel); } - container.appendChild(card); + container.appendChild(tabGroup); } const scoreDiv = document.createElement('div'); scoreDiv.className = 'solver-score'; - scoreDiv.textContent = 'Score: ' + result.score; + scoreDiv.textContent = 'Score: ' + (solutions[0]?.score ?? 0) + (solutions.length > 1 ? ' (' + solutions.length + ' options)' : ''); container.appendChild(scoreDiv); } catch (e) { const container = document.getElementById('solver-results');