Return all top-scoring solver configurations and display as tabs

This commit is contained in:
Ian Gulliver
2026-02-16 11:17:24 -08:00
parent 4bc13830ad
commit 5350a9ab1f
2 changed files with 145 additions and 76 deletions

105
main.go
View File

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

View File

@@ -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');