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:
@@ -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
|
||||
|
||||
// Recorder is available - calculate score
|
||||
const locationScore = getLocationScore(dancerUser, recorder.user);
|
||||
|
||||
if (coverableHeats.length > 0) {
|
||||
candidates.push({
|
||||
recorder,
|
||||
locationScore: getLocationScore(dancerUser, recorder.user),
|
||||
isOptOut: recorder.recorderOptOut,
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user