fix(matching): improve collision detection and load balancing

- Add dual buffer system: BEFORE (prep) and AFTER (rest) dancing
- Track recording assignments to prevent double-booking recorders
- Fix sorting priority: location score takes precedence over load balancing
- Simplify opt-out logic with complete exclusion from matching pool
- Buffers apply only to dancing heats, not recording assignments
- Improve documentation clarity for algorithm constraints
This commit is contained in:
Radosław Gierwiało
2025-11-29 21:42:22 +01:00
parent a9c46f552f
commit 029b25c9b2

View File

@@ -2,11 +2,17 @@
* Auto-matching service for recording partners * Auto-matching service for recording partners
* *
* Matches dancers with recorders based on: * Matches dancers with recorders based on:
* - Heat collision avoidance (can't record while dancing) * - Heat collision avoidance (can't record while dancing or already recording)
* - Schedule config (divisions in same slot collide) * - Schedule config (divisions in same slot collide)
* - Buffer time (1 heat after dancing) * - Buffer time BEFORE dancing (1 heat prep time)
* - Buffer time AFTER dancing (1 heat rest time)
* - Buffers only apply to dancing, NOT to recording
* - Location preference (same city > same country > anyone) * - Location preference (same city > same country > anyone)
* - Load balancing (distribute assignments evenly)
* - Max recordings per person (3) * - Max recordings per person (3)
* - Opt-out users are completely excluded from matching
*
* Sorting priority: Location > Load balancing
*/ */
const { prisma } = require('../utils/db'); const { prisma } = require('../utils/db');
@@ -14,7 +20,8 @@ const { SUGGESTION_STATUS } = require('../constants');
// Constants // Constants
const MAX_RECORDINGS_PER_PERSON = 3; const MAX_RECORDINGS_PER_PERSON = 3;
const HEAT_BUFFER = 1; // Number of heats after dancing before can record const HEAT_BUFFER_BEFORE = 1; // Number of heats BEFORE dancing when unavailable (prep time)
const HEAT_BUFFER_AFTER = 1; // Number of heats AFTER dancing when unavailable (rest time)
/** /**
* Build division-to-slot mapping from schedule config * Build division-to-slot mapping from schedule config
@@ -59,26 +66,60 @@ function getTimeSlot(heat, divisionSlotMap = null) {
} }
/** /**
* Get adjacent slots (for buffer calculation) * Get buffer slots BEFORE dancing (preparation time)
* Returns the next slot within the same slot group * Returns slots immediately before this heat where user is unavailable
*/ */
function getBufferSlots(heat, divisionSlotMap = null) { function getPreDanceBufferSlots(heat, divisionSlotMap = null) {
const bufferSlots = []; const bufferSlots = [];
if (divisionSlotMap && divisionSlotMap.size > 0) { if (divisionSlotMap && divisionSlotMap.size > 0) {
const slotOrder = divisionSlotMap.get(heat.divisionId); const slotOrder = divisionSlotMap.get(heat.divisionId);
if (slotOrder !== undefined) { if (slotOrder !== undefined) {
for (let i = 1; i <= HEAT_BUFFER; i++) { for (let i = 1; i <= HEAT_BUFFER_BEFORE; i++) {
bufferSlots.push(`slot${slotOrder}-${heat.competitionTypeId}-${heat.heatNumber + i}`); const prevHeatNumber = heat.heatNumber - i;
if (prevHeatNumber > 0) {
bufferSlots.push(`slot${slotOrder}-${heat.competitionTypeId}-${prevHeatNumber}`);
}
} }
return bufferSlots; return bufferSlots;
} }
} }
// Fallback: division-based buffer // Fallback: division-based buffer
for (let i = 1; i <= HEAT_BUFFER; i++) { for (let i = 1; i <= HEAT_BUFFER_BEFORE; i++) {
bufferSlots.push(`${heat.divisionId}-${heat.competitionTypeId}-${heat.heatNumber + i}`); const prevHeatNumber = heat.heatNumber - i;
if (prevHeatNumber > 0) {
bufferSlots.push(`${heat.divisionId}-${heat.competitionTypeId}-${prevHeatNumber}`);
} }
}
return bufferSlots;
}
/**
* Get buffer slots AFTER dancing (rest time)
* Returns slots immediately after this heat where user is unavailable
*/
function getPostDanceBufferSlots(heat, divisionSlotMap = null) {
const bufferSlots = [];
if (divisionSlotMap && divisionSlotMap.size > 0) {
const slotOrder = divisionSlotMap.get(heat.divisionId);
if (slotOrder !== undefined) {
for (let i = 1; i <= HEAT_BUFFER_AFTER; i++) {
const nextHeatNumber = heat.heatNumber + i;
bufferSlots.push(`slot${slotOrder}-${heat.competitionTypeId}-${nextHeatNumber}`);
}
return bufferSlots;
}
}
// Fallback: division-based buffer
for (let i = 1; i <= HEAT_BUFFER_AFTER; i++) {
const nextHeatNumber = heat.heatNumber + i;
bufferSlots.push(`${heat.divisionId}-${heat.competitionTypeId}-${nextHeatNumber}`);
}
return bufferSlots; return bufferSlots;
} }
@@ -99,8 +140,12 @@ function hasCollision(dancerHeats, recorderHeats, divisionSlotMap = null) {
const recorderBusySlots = new Set(); const recorderBusySlots = new Set();
for (const heat of recorderHeats) { for (const heat of recorderHeats) {
recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap)); recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap));
// Add buffer after their heats // Add buffer before their heats (prep time)
for (const bufferSlot of getBufferSlots(heat, divisionSlotMap)) { for (const bufferSlot of getPreDanceBufferSlots(heat, divisionSlotMap)) {
recorderBusySlots.add(bufferSlot);
}
// Add buffer after their heats (rest time)
for (const bufferSlot of getPostDanceBufferSlots(heat, divisionSlotMap)) {
recorderBusySlots.add(bufferSlot); recorderBusySlots.add(bufferSlot);
} }
} }
@@ -125,7 +170,10 @@ function getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap = null) {
for (const heat of recorderHeats) { for (const heat of recorderHeats) {
recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap)); recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap));
for (const bufferSlot of getBufferSlots(heat, divisionSlotMap)) { for (const bufferSlot of getPreDanceBufferSlots(heat, divisionSlotMap)) {
recorderBusySlots.add(bufferSlot);
}
for (const bufferSlot of getPostDanceBufferSlots(heat, divisionSlotMap)) {
recorderBusySlots.add(bufferSlot); recorderBusySlots.add(bufferSlot);
} }
} }
@@ -157,6 +205,12 @@ function getLocationScore(dancer, recorder) {
/** /**
* Main matching algorithm * Main matching algorithm
* Greedy approach: for each heat, find the best available recorder * Greedy approach: for each heat, find the best available recorder
*
* Fixed bugs:
* - Tracks already assigned recordings to prevent double-booking recorders
* - Properly handles empty candidate lists
* - Simplified opt-out logic (complete exclusion)
* - Correct sorting priority: location > load balancing
*/ */
async function runMatching(eventId) { async function runMatching(eventId) {
// 0. Load event with schedule config // 0. Load event with schedule config
@@ -206,21 +260,37 @@ async function runMatching(eventId) {
// 3. Identify dancers (have competitor number) vs potential recorders // 3. Identify dancers (have competitor number) vs potential recorders
const dancers = participants.filter(p => p.competitorNumber !== null); const dancers = participants.filter(p => p.competitorNumber !== null);
// Opt-out users are completely excluded from matching
const potentialRecorders = participants.filter(p => !p.recorderOptOut); const potentialRecorders = participants.filter(p => !p.recorderOptOut);
// Sort recorders: non-opt-out first, then by whether they're dancing // 4. Track recorder assignments and busy slots
potentialRecorders.sort((a, b) => {
// Opt-out users go to the end
if (a.recorderOptOut !== b.recorderOptOut) {
return a.recorderOptOut ? 1 : -1;
}
return 0;
});
// 4. Track recorder assignments
const recorderAssignmentCount = new Map(); // recorderId -> count const recorderAssignmentCount = new Map(); // recorderId -> count
const recorderBusySlots = new Map(); // recorderId -> Set<slotString>
const suggestions = []; // Final suggestions const suggestions = []; // Final suggestions
// 4a. Initialize busy slots with heats where recorders are DANCING (+ buffers)
for (const participant of participants) {
const userHeats = heatsByUser.get(participant.userId) || [];
const busySlots = new Set();
for (const heat of userHeats) {
const slot = getTimeSlot(heat, divisionSlotMap);
busySlots.add(slot);
// Buffer BEFORE DANCING (prep time)
for (const bufferSlot of getPreDanceBufferSlots(heat, divisionSlotMap)) {
busySlots.add(bufferSlot);
}
// Buffer AFTER DANCING (rest time)
for (const bufferSlot of getPostDanceBufferSlots(heat, divisionSlotMap)) {
busySlots.add(bufferSlot);
}
}
recorderBusySlots.set(participant.userId, busySlots);
}
// 5. For each dancer, find recorders for their heats // 5. For each dancer, find recorders for their heats
for (const dancer of dancers) { for (const dancer of dancers) {
const dancerHeats = heatsByUser.get(dancer.userId) || []; const dancerHeats = heatsByUser.get(dancer.userId) || [];
@@ -230,16 +300,17 @@ async function runMatching(eventId) {
// Sort heats by time slot for deterministic ordering // Sort heats by time slot for deterministic ordering
dancerHeats.sort((a, b) => { dancerHeats.sort((a, b) => {
const slotA = getTimeSlot(a); const slotA = getTimeSlot(a, divisionSlotMap);
const slotB = getTimeSlot(b); const slotB = getTimeSlot(b, divisionSlotMap);
return slotA.localeCompare(slotB); return slotA.localeCompare(slotB);
}); });
// For each heat, find a recorder // For each heat, find a recorder
for (const heat of dancerHeats) { for (const heat of dancerHeats) {
// Find available recorders for this heat const heatSlot = getTimeSlot(heat, divisionSlotMap);
const candidates = []; const candidates = [];
// 5a. Build candidate list for THIS heat
for (const recorder of potentialRecorders) { for (const recorder of potentialRecorders) {
// Skip self // Skip self
if (recorder.userId === dancer.userId) continue; if (recorder.userId === dancer.userId) continue;
@@ -248,22 +319,22 @@ async function runMatching(eventId) {
const currentCount = recorderAssignmentCount.get(recorder.userId) || 0; const currentCount = recorderAssignmentCount.get(recorder.userId) || 0;
if (currentCount >= MAX_RECORDINGS_PER_PERSON) continue; if (currentCount >= MAX_RECORDINGS_PER_PERSON) continue;
// Check if this recorder can cover this specific heat // Check if recorder is busy (dancing OR already assigned to record)
const recorderHeats = heatsByUser.get(recorder.userId) || []; const busySlots = recorderBusySlots.get(recorder.userId) || new Set();
const coverableHeats = getCoverableHeats([heat], recorderHeats, divisionSlotMap); if (busySlots.has(heatSlot)) continue; // Collision - skip
// Recorder is available - calculate score
const locationScore = getLocationScore(dancerUser, recorder.user);
if (coverableHeats.length > 0) {
candidates.push({ candidates.push({
recorder, recorder,
locationScore: getLocationScore(dancerUser, recorder.user), locationScore,
isOptOut: recorder.recorderOptOut,
currentAssignments: currentCount, currentAssignments: currentCount,
}); });
} }
}
// 5b. No candidates available - mark as unassigned
if (candidates.length === 0) { if (candidates.length === 0) {
// No recorder found for this heat
suggestions.push({ suggestions.push({
eventId, eventId,
heatId: heat.id, heatId: heat.id,
@@ -273,14 +344,17 @@ async function runMatching(eventId) {
continue; continue;
} }
// Sort candidates: location score (desc), assignments (asc), opt-out last // 5c. Sort candidates: location first, then load balancing
candidates.sort((a, b) => { candidates.sort((a, b) => {
if (a.isOptOut !== b.isOptOut) return a.isOptOut ? 1 : -1; // Higher location score = better
if (a.locationScore !== b.locationScore) return b.locationScore - a.locationScore; if (a.locationScore !== b.locationScore) {
return b.locationScore - a.locationScore;
}
// Fewer assignments = better (load balancing)
return a.currentAssignments - b.currentAssignments; return a.currentAssignments - b.currentAssignments;
}); });
// Pick the best candidate // 5d. Pick the best candidate
const best = candidates[0]; const best = candidates[0];
suggestions.push({ suggestions.push({
eventId, eventId,
@@ -289,11 +363,17 @@ async function runMatching(eventId) {
status: SUGGESTION_STATUS.PENDING, status: SUGGESTION_STATUS.PENDING,
}); });
// Update assignment count // 5e. Update assignment count
recorderAssignmentCount.set( recorderAssignmentCount.set(
best.recorder.userId, best.recorder.userId,
(recorderAssignmentCount.get(best.recorder.userId) || 0) + 1 (recorderAssignmentCount.get(best.recorder.userId) || 0) + 1
); );
// 5f. Mark this slot as busy for the recorder (now they're recording)
const bestBusySlots = recorderBusySlots.get(best.recorder.userId) || new Set();
bestBusySlots.add(heatSlot);
// Note: We don't add buffers after recording, only before/after DANCING
recorderBusySlots.set(best.recorder.userId, bestBusySlots);
} }
} }
@@ -397,9 +477,9 @@ module.exports = {
hasCollision, hasCollision,
getCoverableHeats, getCoverableHeats,
getTimeSlot, getTimeSlot,
getBufferSlots, getPreDanceBufferSlots,
getLocationScore, getLocationScore,
buildDivisionSlotMap, buildDivisionSlotMap,
MAX_RECORDINGS_PER_PERSON, MAX_RECORDINGS_PER_PERSON,
HEAT_BUFFER, HEAT_BUFFER_BEFORE,
}; };