I am building a guard scheduling system using Google OR-Tools (CP-SAT). I have a "Slot Rotation" constraint designed to ensure that the same user doesn't work consecutive shifts.
The Setup: I have two assignments (Patrol and Checkpoints) running in parallel. The shifts are hourly.
Assignment A (Patrol): 08:00, 09:00, 10:00...
Assignment B (Checkpoints): 08:00, 09:00, 10:00...
The Problem: Even with a "Strict Rotation" constraint enabled, the solver is assigning the same user to back-to-back hours (e.g., usr_015 works 09:00–10:00 in Patrol and then 10:00–11:00 in Patrol).
My Constraint Logic: My compiler sorts all slots in the roster by startTime. When assignments are parallel, the sequence looks like this: [Patrol_08:00, Checkpoint_08:00, Patrol_09:00, Checkpoint_09:00, Patrol_10:00...]
I am using a window of 2 for my rotation, which logic suggests should prevent a user from appearing in "the next slot."
Python
# Simplified snippet of my SlotRotationCompiler
def compile(self, block, constraint, context):
# Slots are sorted globally by startTime
sorted_slots = sorted(context.slots, key=lambda s: s.start_time)
window = block.params.get("window", 2) # consecutive = window 2
for i in range(len(sorted_slots)):
current_slot = sorted_slots[i]
# Look ahead within the window
for j in range(1, window):
if i + j < len(sorted_slots):
next_slot = sorted_slots[i + j]
# Forbid same user in current_slot and next_slot
for user_id in context.user_ids:
model.Add(user_vars[user_id, current_slot] +
user_vars[user_id, next_slot] <= 1)
The Loophole: Because the assignments are parallel, Patrol_09:00 (index 2) and Patrol_10:00 (index 4) are not adjacent in the sorted list. Checkpoint_09:00 (index 3) is between them.
With a window of 2, the solver:
Prevents User A from working
Patrol_09:00ANDCheckpoint_09:00.Allows User A to work
Patrol_09:00ANDPatrol_10:00because they are 2 indices apart, effectively "skipping" over the rotation check.
The Results I'm getting:
JSON
{
"slotId": "p_2", "startTime": "09:00", "userId": "usr_015",
"slotId": "p_3", "startTime": "10:00", "userId": "usr_015"
// This is a back-to-back hour shift!
}
Question: How should I structure my constraint or my slot compilation to ensure that "Rotation" accounts for actual temporal adjacency across parallel assignments, rather than just index adjacency in a list? Should I be using a MinRestGap approach instead of SlotRotation, or is there a better way to calculate the window dynamically based on the number of concurrent assignments?