From 32750d0d67c9e2abd57f4b30664ffbd28b1772e3 Mon Sep 17 00:00:00 2001 From: Kris Riisgaard Larsen Date: Sun, 18 Oct 2026 14:22:30 +0200 Subject: [PATCH] feat(scheduler): rotate deterministic tie-breakers across days Preserve risk, preference, load and skill ordering; only rotate otherwise equal candidates by calendar date. Include regression tests for input order, daily rotation, and skill eligibility. --- app/scheduler.py | 19 +++++++++++++++++-- tests/test_scheduler.py | 26 ++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/app/scheduler.py b/app/scheduler.py index d7e8cb2..36277ee 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -1,3 +1,5 @@ +from datetime import date + from app.constants import RiskLevel from app.models import Employee, EmployeeProfile, Shift from app.personalized_workload import calculate_personalized_workload_score @@ -26,6 +28,20 @@ def assign_shifts( for shift in sorted_shifts: eligible = [e for e in employees if e.can_take_shift(shift)] + # Rotate only the final tie-breaker: risk, workload, preferences and + # skill flexibility always take precedence. This avoids repeatedly + # picking the first alphabetical name for otherwise equal shifts. + names_in_order = sorted(eligible, key=lambda e: (e.name.casefold(), e.name)) + start_index = ( + (date.fromisoformat(shift.date) - date(1970, 1, 1)).days + % len(names_in_order) + if names_in_order else 0 + ) + rotating_rank = { + id(employee): (index - start_index) % len(names_in_order) + for index, employee in enumerate(names_in_order) + } + def sort_key(e: Employee): profile = profiles_by_name.get(e.name) @@ -46,8 +62,7 @@ def sort_key(e: Employee): personalized_score, e.total_workload_score, len(e.skills), - e.name.casefold(), - e.name, + rotating_rank[id(e)], ) sorted_employees = sorted(eligible, key=sort_key) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index f672a1a..439e151 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -193,8 +193,7 @@ def chosen_name(order): assign_shifts(employees, [shift]) return shift.assigned_employees[0].name - assert chosen_name(["Zoe", "Anna"]) == "Anna" - assert chosen_name(["Anna", "Zoe"]) == "Anna" + assert chosen_name(["Zoe", "Anna"]) == chosen_name(["Anna", "Zoe"]) def test_equal_assignment_count_prefers_lower_total_workload(): @@ -216,3 +215,26 @@ def test_equal_assignment_count_prefers_lower_total_workload(): ) assert next_shift.assigned_employees == [lightly_loaded] + + +def test_equal_priority_ties_rotate_across_consecutive_days(): + def assigned_name(day): + employees = [Employee("Anna", 5, ["CT"]), Employee("Zoe", 5, ["CT"])] + shift = Shift(day, ShiftType.DAY, "CT") + assign_shifts(employees, [shift]) + return shift.assigned_employees[0].name + + monday = assigned_name("2026-05-11") + tuesday = assigned_name("2026-05-12") + + assert {monday, tuesday} == {"Anna", "Zoe"} + + +def test_rotation_does_not_override_skill_requirements(): + anna = Employee("Anna", 4, ["MR"]) + zoe = Employee("Zoe", 4, ["CT"]) + shift = Shift("2026-05-12", ShiftType.DAY, "CT") + + assign_shifts([anna, zoe], [shift]) + + assert shift.assigned_employees == [zoe]