meeting-scheduling-python / tests /test_constraints.py
blackopsrepl's picture
Upload 31 files
666f6cf verified
from solverforge_legacy.solver.test import ConstraintVerifier
from meeting_scheduling.domain import (
Meeting,
MeetingAssignment,
MeetingSchedule,
Person,
PreferredAttendance,
RequiredAttendance,
Room,
TimeGrain,
)
from meeting_scheduling.constraints import (
define_constraints,
room_conflict,
avoid_overtime,
required_attendance_conflict,
required_room_capacity,
start_and_end_on_same_day,
required_and_preferred_attendance_conflict,
preferred_attendance_conflict,
room_stability,
)
DEFAULT_TIME_GRAINS = [
TimeGrain(
id=str(i + 1), grain_index=i, day_of_year=1, starting_minute_of_day=480 + i * 15
)
for i in range(8)
]
DEFAULT_ROOM = Room(id="1", name="Room 1", capacity=10)
SMALL_ROOM = Room(id="2", name="Small Room", capacity=1)
LARGE_ROOM = Room(id="3", name="Large Room", capacity=2)
ROOM_A = Room(id="4", name="Room A", capacity=10)
ROOM_B = Room(id="5", name="Room B", capacity=10)
constraint_verifier = ConstraintVerifier.build(
define_constraints, MeetingSchedule, MeetingAssignment
)
def test_room_conflict_unpenalized():
"""Test that no penalty is applied when meetings in the same room do not overlap."""
meeting1 = create_meeting(1)
left_assignment = create_meeting_assignment(
0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
)
meeting2 = create_meeting(2)
right_assignment = create_meeting_assignment(
1, meeting2, DEFAULT_TIME_GRAINS[4], DEFAULT_ROOM
)
constraint_verifier.verify_that(room_conflict).given(
left_assignment, right_assignment
).penalizes(0)
def test_room_conflict_penalized():
"""Test that a penalty is applied when meetings in the same room overlap."""
meeting1 = create_meeting(1)
left_assignment = create_meeting_assignment(
0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
)
meeting2 = create_meeting(2)
right_assignment = create_meeting_assignment(
1, meeting2, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
)
constraint_verifier.verify_that(room_conflict).given(
left_assignment, right_assignment
).penalizes_by(2)
def test_avoid_overtime_unpenalized():
"""Test that no penalty is applied when a meeting fits within available time grains (no overtime)."""
meeting = create_meeting(1)
meeting_assignment = create_meeting_assignment(
0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
)
constraint_verifier.verify_that(avoid_overtime).given(
meeting_assignment, *DEFAULT_TIME_GRAINS
).penalizes(0)
def test_avoid_overtime_penalized():
"""Test that a penalty is applied when a meeting exceeds available time grains (overtime)."""
meeting = create_meeting(1)
meeting_assignment = create_meeting_assignment(
0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
)
constraint_verifier.verify_that(avoid_overtime).given(
meeting_assignment
).penalizes_by(3)
def test_required_attendance_conflict_unpenalized():
"""Test that no penalty is applied when a person does not have overlapping required meetings."""
person = create_person(1)
left_meeting = create_meeting(1, duration=2)
required_attendance1 = create_required_attendance(0, person, left_meeting)
right_meeting = create_meeting(2, duration=2)
required_attendance2 = create_required_attendance(1, person, right_meeting)
left_assignment = create_meeting_assignment(
0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
)
right_assignment = create_meeting_assignment(
1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
)
constraint_verifier.verify_that(required_attendance_conflict).given(
required_attendance1, required_attendance2, left_assignment, right_assignment
).penalizes(0)
def test_required_attendance_conflict_penalized():
"""Test that a penalty is applied when a person has overlapping required meetings."""
person = create_person(1)
left_meeting = create_meeting(1, duration=2)
required_attendance1 = create_required_attendance(0, person, left_meeting)
right_meeting = create_meeting(2, duration=2)
required_attendance2 = create_required_attendance(1, person, right_meeting)
left_assignment = create_meeting_assignment(
0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
)
right_assignment = create_meeting_assignment(
1, right_meeting, DEFAULT_TIME_GRAINS[1], DEFAULT_ROOM
)
constraint_verifier.verify_that(required_attendance_conflict).given(
required_attendance1, required_attendance2, left_assignment, right_assignment
).penalizes_by(1)
def test_required_room_capacity_unpenalized():
"""Test that no penalty is applied when the room has enough capacity for all required and preferred attendees."""
person1 = create_person(1)
person2 = create_person(2)
meeting = create_meeting(1, duration=2)
create_required_attendance(0, person1, meeting)
create_preferred_attendance(1, person2, meeting)
meeting_assignment = create_meeting_assignment(
0, meeting, DEFAULT_TIME_GRAINS[0], LARGE_ROOM
)
constraint_verifier.verify_that(required_room_capacity).given(
meeting_assignment
).penalizes(0)
def test_required_room_capacity_penalized():
"""Test that a penalty is applied when the room does not have enough capacity for all required and preferred attendees."""
person1 = create_person(1)
person2 = create_person(2)
meeting = create_meeting(1, duration=2)
create_required_attendance(0, person1, meeting)
create_preferred_attendance(1, person2, meeting)
meeting_assignment = create_meeting_assignment(
0, meeting, DEFAULT_TIME_GRAINS[0], SMALL_ROOM
)
constraint_verifier.verify_that(required_room_capacity).given(
meeting_assignment
).penalizes_by(1)
def test_start_and_end_on_same_day_unpenalized():
"""Test that no penalty is applied when a meeting starts and ends on the same day."""
# Need custom time grains with day_of_year=0 (DEFAULT_TIME_GRAINS use day_of_year=1)
start_time_grain = TimeGrain(
id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480
)
end_time_grain = TimeGrain(
id="2", grain_index=3, day_of_year=0, starting_minute_of_day=525
) # Same day
meeting = create_meeting(1)
meeting_assignment = create_meeting_assignment(
0, meeting, start_time_grain, DEFAULT_ROOM
)
constraint_verifier.verify_that(start_and_end_on_same_day).given(
meeting_assignment, end_time_grain
).penalizes(0)
def test_start_and_end_on_same_day_penalized():
"""Test that a penalty is applied when a meeting starts and ends on different days."""
# Need custom time grains to test different days (start=day 0, end=day 1)
start_time_grain = TimeGrain(
id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480
)
end_time_grain = TimeGrain(
id="2", grain_index=3, day_of_year=1, starting_minute_of_day=525
) # Different day
meeting = create_meeting(1)
meeting_assignment = create_meeting_assignment(
0, meeting, start_time_grain, DEFAULT_ROOM
)
constraint_verifier.verify_that(start_and_end_on_same_day).given(
meeting_assignment, end_time_grain
).penalizes_by(1)
def test_multiple_constraint_violations():
"""Test that multiple constraints can be violated simultaneously."""
person = create_person(1)
left_meeting = create_meeting(1)
required_attendance1 = create_required_attendance(0, person, left_meeting)
left_assignment = create_meeting_assignment(
0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
)
right_meeting = create_meeting(2)
required_attendance2 = create_required_attendance(1, person, right_meeting)
right_assignment = create_meeting_assignment(
1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
)
constraint_verifier.verify_that(room_conflict).given(
left_assignment, right_assignment
).penalizes_by(2)
constraint_verifier.verify_that(required_attendance_conflict).given(
required_attendance1, required_attendance2, left_assignment, right_assignment
).penalizes_by(2)
### Helper functions ###
def create_meeting(id, topic="Meeting", duration=4):
"""Helper to create a meeting with standard parameters."""
return Meeting(id=str(id), topic=f"{topic} {id}", duration_in_grains=duration)
def create_meeting_assignment(id, meeting, time_grain, room):
"""Helper to create a meeting assignment."""
return MeetingAssignment(
id=str(id), meeting=meeting, starting_time_grain=time_grain, room=room
)
def create_person(id):
"""Helper to create a person."""
return Person(id=str(id), full_name=f"Person {id}")
def create_required_attendance(id, person, meeting):
"""Helper to create and link required attendance."""
attendance = RequiredAttendance(id=str(id), person=person, meeting_id=meeting.id)
meeting.required_attendances = [attendance]
return attendance
def create_preferred_attendance(id, person, meeting):
"""Helper to create and link preferred attendance."""
attendance = PreferredAttendance(id=str(id), person=person, meeting_id=meeting.id)
meeting.preferred_attendances = [attendance]
return attendance
# ========================================
# Required and Preferred Attendance Conflict Tests
# ========================================
def test_required_and_preferred_attendance_conflict_unpenalized():
"""Test no penalty when required and preferred meetings don't overlap."""
person = create_person(1)
# Meeting 1: grain 0-3 (duration=4), person required
meeting1 = create_meeting(1, duration=4)
attendance1 = create_required_attendance(0, person, meeting1)
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
# Meeting 2: grain 4-7 (duration=4), person preferred
meeting2 = create_meeting(2, duration=4)
attendance2 = create_preferred_attendance(1, person, meeting2)
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[4], ROOM_A)
constraint_verifier.verify_that(required_and_preferred_attendance_conflict).given(
attendance1, attendance2, assignment1, assignment2
).penalizes_by(0)
def test_required_and_preferred_attendance_conflict_penalized():
"""Test penalty when person required at one meeting and preferred at overlapping meeting."""
person = create_person(1)
# Meeting 1: grain 0-3 (duration=4), person required
meeting1 = create_meeting(1, duration=4)
attendance1 = create_required_attendance(0, person, meeting1)
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
# Meeting 2: grain 2-5 (duration=4), person preferred, overlaps grains 2-3 (2 grains)
meeting2 = create_meeting(2, duration=4)
attendance2 = create_preferred_attendance(1, person, meeting2)
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[2], ROOM_A)
# Overlap: grains 2-3 = 2 grains
constraint_verifier.verify_that(required_and_preferred_attendance_conflict).given(
attendance1, attendance2, assignment1, assignment2
).penalizes_by(2)
# ========================================
# Preferred Attendance Conflict Tests
# ========================================
def test_preferred_attendance_conflict_unpenalized():
"""Test no penalty when preferred attendee has non-overlapping meetings."""
person = create_person(1)
# Meeting 1: grain 0-3 (duration=4), person preferred
meeting1 = create_meeting(1, duration=4)
attendance1 = create_preferred_attendance(0, person, meeting1)
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
# Meeting 2: grain 4-7 (duration=4), person preferred
meeting2 = create_meeting(2, duration=4)
attendance2 = create_preferred_attendance(1, person, meeting2)
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[4], ROOM_A)
constraint_verifier.verify_that(preferred_attendance_conflict).given(
attendance1, attendance2, assignment1, assignment2
).penalizes_by(0)
def test_preferred_attendance_conflict_penalized():
"""Test penalty when person preferred at multiple overlapping meetings."""
person = create_person(1)
# Meeting 1: grain 0-3 (duration=4), person preferred
meeting1 = create_meeting(1, duration=4)
attendance1 = create_preferred_attendance(0, person, meeting1)
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
# Meeting 2: grain 1-4 (duration=4), person preferred, overlaps grains 1-3 (3 grains)
meeting2 = create_meeting(2, duration=4)
attendance2 = create_preferred_attendance(1, person, meeting2)
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[1], ROOM_A)
# Overlap: grains 1-3 = 3 grains
constraint_verifier.verify_that(preferred_attendance_conflict).given(
attendance1, attendance2, assignment1, assignment2
).penalizes_by(3)
# ========================================
# Room Stability Tests
# ========================================
def test_room_stability_same_room_no_penalty():
"""
Test that no penalty is applied when a person attends consecutive
meetings in the same room (stability is maintained).
"""
person = create_person(1)
# Meeting 1: time grain 0-1 (duration=2) in ROOM_A
meeting1 = create_meeting(1, duration=2)
attendance1 = create_required_attendance(0, person, meeting1)
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)
# Meeting 2: time grain 3-4 (duration=2) in ROOM_A (same room, gap of 1)
meeting2 = create_meeting(2, duration=2)
attendance2 = create_required_attendance(1, person, meeting2)
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[3], ROOM_A)
# Same room should not penalize
constraint_verifier.verify_that(room_stability).given(
attendance1, attendance2, assignment1, assignment2
).penalizes(0)
def test_room_stability_different_room_with_required_attendance():
"""
Test that a penalty is applied when a person with required attendance
has to change rooms between closely scheduled meetings.
Weighted penalty: back-to-back switches cost more than switches with gaps.
"""
person = create_person(1)
# Meeting 1: time grain 0-1 (duration=2) in ROOM_A
left_grain_index = 0
left_duration = 2
meeting1 = create_meeting(1, duration=left_duration)
attendance1 = create_required_attendance(0, person, meeting1)
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
# Meeting 2: time grain 3-4 (duration=2) in ROOM_B (different room)
right_grain_index = 3
meeting2 = create_meeting(2, duration=2)
attendance2 = create_required_attendance(1, person, meeting2)
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
# Weighted penalty: 3 - gap, where gap = right_grain - left_duration - left_grain
gap = right_grain_index - left_duration - left_grain_index
expected_penalty = 3 - gap
constraint_verifier.verify_that(room_stability).given(
attendance1, attendance2, assignment1, assignment2
).penalizes_by(expected_penalty)
def test_room_stability_different_room_with_preferred_attendance():
"""
Test that a penalty is applied when a person with preferred attendance
has to change rooms between closely scheduled meetings.
Weighted penalty applies to preferred attendance too.
"""
person = create_person(1)
# Meeting 1: time grain 0-1 (duration=2) in ROOM_A
left_grain_index = 0
left_duration = 2
meeting1 = create_meeting(1, duration=left_duration)
attendance1 = create_preferred_attendance(0, person, meeting1)
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
# Meeting 2: time grain 3-4 (duration=2) in ROOM_B (different room)
right_grain_index = 3
meeting2 = create_meeting(2, duration=2)
attendance2 = create_preferred_attendance(1, person, meeting2)
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
# Weighted penalty: 3 - gap
gap = right_grain_index - left_duration - left_grain_index
expected_penalty = 3 - gap
constraint_verifier.verify_that(room_stability).given(
attendance1, attendance2, assignment1, assignment2
).penalizes_by(expected_penalty)
def test_room_stability_mixed_attendance_types():
"""
Test that room stability penalty applies when mixing required and preferred
attendance types for the same person.
"""
person = create_person(1)
# Meeting 1 with required attendance
left_grain_index = 0
left_duration = 2
meeting1 = create_meeting(1, duration=left_duration)
required_attendance = create_required_attendance(0, person, meeting1)
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
# Meeting 2 with preferred attendance
right_grain_index = 3
meeting2 = create_meeting(2, duration=2)
preferred_attendance = create_preferred_attendance(1, person, meeting2)
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
# Weighted penalty: 3 - gap
gap = right_grain_index - left_duration - left_grain_index
expected_penalty = 3 - gap
constraint_verifier.verify_that(room_stability).given(
required_attendance, preferred_attendance, assignment1, assignment2
).penalizes_by(expected_penalty)
def test_room_stability_far_apart_meetings_no_penalty():
"""
Test that no penalty is applied when meetings are far apart in time,
even if they're in different rooms.
"""
person = create_person(1)
# Meeting 1: time grain 0-1 (duration=2) in ROOM_A
meeting1 = create_meeting(1, duration=2)
attendance1 = create_required_attendance(0, person, meeting1)
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)
# Meeting 2: time grain 6-7 (duration=2) in ROOM_B
# gap = grain_index(6) - duration_in_grains(2) - grain_index(0) = 6 - 2 - 0 = 4 > 2
meeting2 = create_meeting(2, duration=2)
attendance2 = create_required_attendance(1, person, meeting2)
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[6], ROOM_B)
# Far apart meetings should not penalize even with room change
constraint_verifier.verify_that(room_stability).given(
attendance1, attendance2, assignment1, assignment2
).penalizes(0)
def test_room_stability_different_people_no_penalty():
"""
Test that no penalty is applied when different people have meetings
in different rooms (room stability is per-person).
"""
person1 = create_person(1)
person2 = create_person(2)
# Person 1's meeting in ROOM_A
meeting1 = create_meeting(1, duration=2)
attendance1 = create_required_attendance(0, person1, meeting1)
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)
# Person 2's meeting in ROOM_B (different person, should not affect stability)
meeting2 = create_meeting(2, duration=2)
attendance2 = create_required_attendance(1, person2, meeting2)
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[3], ROOM_B)
# Different people should not trigger room stability penalty
constraint_verifier.verify_that(room_stability).given(
attendance1, attendance2, assignment1, assignment2
).penalizes(0)
def test_room_stability_back_to_back_highest_penalty():
"""
Test that back-to-back room switches (gap=0) incur the highest penalty (3).
This verifies the weighted penalty gradient: closer switches cost more.
"""
person = create_person(1)
# Meeting 1: grain 0-1 (duration=2) in ROOM_A
left_grain_index = 0
left_duration = 2
meeting1 = create_meeting(1, duration=left_duration)
attendance1 = create_required_attendance(0, person, meeting1)
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
# Meeting 2: grain 2-3 (immediately after) in ROOM_B
right_grain_index = 2 # Starts right after meeting1 ends
meeting2 = create_meeting(2, duration=2)
attendance2 = create_required_attendance(1, person, meeting2)
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
# gap = 2 - 2 - 0 = 0, penalty = 3 - 0 = 3
gap = right_grain_index - left_duration - left_grain_index
expected_penalty = 3 - gap
assert gap == 0, f"Test setup error: expected gap=0, got {gap}"
assert expected_penalty == 3, f"Test setup error: expected penalty=3, got {expected_penalty}"
constraint_verifier.verify_that(room_stability).given(
attendance1, attendance2, assignment1, assignment2
).penalizes_by(expected_penalty)