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
*
* 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)
* - 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)
* - Load balancing (distribute assignments evenly)
* - Max recordings per person (3)
* - Opt-out users are completely excluded from matching
*
* Sorting priority: Location > Load balancing
*/
const { prisma } = require('../utils/db');
@@ -14,7 +20,8 @@ const { SUGGESTION_STATUS } = require('../constants');
// Constants
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
@@ -59,26 +66,60 @@ function getTimeSlot(heat, divisionSlotMap = null) {
}
/**
* Get adjacent slots (for buffer calculation)
* Returns the next slot within the same slot group
* Get buffer slots BEFORE dancing (preparation time)
* Returns slots immediately before this heat where user is unavailable
*/
function getBufferSlots(heat, divisionSlotMap = null) {
function getPreDanceBufferSlots(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; i++) {
bufferSlots.push(`slot${slotOrder}-${heat.competitionTypeId}-${heat.heatNumber + i}`);
for (let i = 1; i <= HEAT_BUFFER_BEFORE; i++) {
const prevHeatNumber = heat.heatNumber - i;
if (prevHeatNumber > 0) {
bufferSlots.push(`slot${slotOrder}-${heat.competitionTypeId}-${prevHeatNumber}`);
}
}
return bufferSlots;
}
}
// Fallback: division-based buffer
for (let i = 1; i <= HEAT_BUFFER; i++) {
bufferSlots.push(`${heat.divisionId}-${heat.competitionTypeId}-${heat.heatNumber + i}`);
for (let i = 1; i <= HEAT_BUFFER_BEFORE; 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;
}
@@ -99,8 +140,12 @@ function hasCollision(dancerHeats, recorderHeats, divisionSlotMap = null) {
const recorderBusySlots = new Set();
for (const heat of recorderHeats) {
recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap));
// Add buffer after their heats
for (const bufferSlot of getBufferSlots(heat, divisionSlotMap)) {
// Add buffer before their heats (prep time)
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);
}
}
@@ -125,7 +170,10 @@ function getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap = null) {
for (const heat of recorderHeats) {
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);
}
}
@@ -157,6 +205,12 @@ function getLocationScore(dancer, recorder) {
/**
* Main matching algorithm
* 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) {
// 0. Load event with schedule config
@@ -206,21 +260,37 @@ async function runMatching(eventId) {
// 3. Identify dancers (have competitor number) vs potential recorders
const dancers = participants.filter(p => p.competitorNumber !== null);
// Opt-out users are completely excluded from matching
const potentialRecorders = participants.filter(p => !p.recorderOptOut);
// Sort recorders: non-opt-out first, then by whether they're dancing
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
// 4. Track recorder assignments and busy slots
const recorderAssignmentCount = new Map(); // recorderId -> count
const recorderBusySlots = new Map(); // recorderId -> Set<slotString>
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
for (const dancer of dancers) {
const dancerHeats = heatsByUser.get(dancer.userId) || [];
@@ -230,16 +300,17 @@ async function runMatching(eventId) {
// Sort heats by time slot for deterministic ordering
dancerHeats.sort((a, b) => {
const slotA = getTimeSlot(a);
const slotB = getTimeSlot(b);
const slotA = getTimeSlot(a, divisionSlotMap);
const slotB = getTimeSlot(b, divisionSlotMap);
return slotA.localeCompare(slotB);
});
// For each heat, find a recorder
for (const heat of dancerHeats) {
// Find available recorders for this heat
const heatSlot = getTimeSlot(heat, divisionSlotMap);
const candidates = [];
// 5a. Build candidate list for THIS heat
for (const recorder of potentialRecorders) {
// Skip self
if (recorder.userId === dancer.userId) continue;
@@ -248,22 +319,22 @@ async function runMatching(eventId) {
const currentCount = recorderAssignmentCount.get(recorder.userId) || 0;
if (currentCount >= MAX_RECORDINGS_PER_PERSON) continue;
// Check if this recorder can cover this specific heat
const recorderHeats = heatsByUser.get(recorder.userId) || [];
const coverableHeats = getCoverableHeats([heat], recorderHeats, divisionSlotMap);
// Check if recorder is busy (dancing OR already assigned to record)
const busySlots = recorderBusySlots.get(recorder.userId) || new Set();
if (busySlots.has(heatSlot)) continue; // Collision - skip
if (coverableHeats.length > 0) {
candidates.push({
recorder,
locationScore: getLocationScore(dancerUser, recorder.user),
isOptOut: recorder.recorderOptOut,
currentAssignments: currentCount,
});
}
// Recorder is available - calculate score
const locationScore = getLocationScore(dancerUser, recorder.user);
candidates.push({
recorder,
locationScore,
currentAssignments: currentCount,
});
}
// 5b. No candidates available - mark as unassigned
if (candidates.length === 0) {
// No recorder found for this heat
suggestions.push({
eventId,
heatId: heat.id,
@@ -273,14 +344,17 @@ async function runMatching(eventId) {
continue;
}
// Sort candidates: location score (desc), assignments (asc), opt-out last
// 5c. Sort candidates: location first, then load balancing
candidates.sort((a, b) => {
if (a.isOptOut !== b.isOptOut) return a.isOptOut ? 1 : -1;
if (a.locationScore !== b.locationScore) return b.locationScore - a.locationScore;
// Higher location score = better
if (a.locationScore !== b.locationScore) {
return b.locationScore - a.locationScore;
}
// Fewer assignments = better (load balancing)
return a.currentAssignments - b.currentAssignments;
});
// Pick the best candidate
// 5d. Pick the best candidate
const best = candidates[0];
suggestions.push({
eventId,
@@ -289,11 +363,17 @@ async function runMatching(eventId) {
status: SUGGESTION_STATUS.PENDING,
});
// Update assignment count
// 5e. Update assignment count
recorderAssignmentCount.set(
best.recorder.userId,
(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,
getCoverableHeats,
getTimeSlot,
getBufferSlots,
getPreDanceBufferSlots,
getLocationScore,
buildDivisionSlotMap,
MAX_RECORDINGS_PER_PERSON,
HEAT_BUFFER,
HEAT_BUFFER_BEFORE,
};