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