Employee Scheduling
Categories:
A comprehensive quickstart guide to understanding and building intelligent employee scheduling with SolverForge. Learn optimization concepts while exploring a working codebase.
Table of Contents
- Introduction
- Getting Started
- The Problem We’re Solving
- Understanding the Data Model
- How Optimization Works
- Writing Constraints: The Business Rules
- The Solver Engine
- Web Interface and API
- Making Your First Customization
- Advanced Constraint Patterns
- Testing and Validation
- Quick Reference
Introduction
What You’ll Learn
This guide walks you through a complete employee scheduling application built with SolverForge, a constraint-based optimization framework. You’ll learn:
- How to model real-world scheduling problems as optimization problems
- How to express business rules as constraints that guide the solution
- How optimization algorithms find high-quality solutions automatically
- How to customize the system for your specific needs
No optimization background required — we’ll explain concepts as we encounter them in the code.
Architecture Note: This guide uses the “fast” implementation pattern with dataclass domain models and Pydantic only at API boundaries. For the architectural reasoning behind this design, see Dataclasses vs Pydantic in Constraint Solvers.
Prerequisites
- Basic Python knowledge (classes, functions, type annotations)
- Familiarity with REST APIs
- Comfort with command-line operations
What is Constraint-Based Optimization?
Traditional programming: You write explicit logic that says “do this, then that.”
Constraint-based optimization: You describe what a good solution looks like and the solver figures out how to achieve it.
Think of it like describing what puzzle pieces you have and what rules they must follow — then having a computer try millions of arrangements per second to find the best fit.
Getting Started
Running the Application
Download and navigate to the project directory:
git clone https://github.com/SolverForge/solverforge-quickstarts cd ./solverforge-quickstarts/fast/employee-scheduling-fastInstall dependencies:
pip install -r requirements.txtStart the server:
python -m employee_scheduling.rest_apiOpen your browser:
http://localhost:8080
You’ll see a scheduling interface with employees, shifts and a “Solve” button. Click it and watch the solver automatically assign employees to shifts while respecting business rules.
File Structure Overview
fast/employee_scheduling-fast/
├── domain.py # Data classes (Employee, Shift, Schedule)
├── constraints.py # Business rules (90% of customization happens here)
├── solver.py # Solver configuration
├── demo_data.py # Sample data generation
├── rest_api.py # HTTP API endpoints
├── converters.py # REST ↔ Domain model conversion
└── json_serialization.py # JSON helpers
static/
├── index.html # Web UI
└── app.js # UI logic and visualization
tests/
├── test_constraints.py # Unit tests for constraints
└── test_feasible.py # Integration tests
Key insight: Most business customization happens in constraints.py alone. You rarely need to modify other files.
The Problem We’re Solving
The Scheduling Challenge
You need to assign employees to shifts while satisfying rules like:
Hard constraints (must be satisfied):
- Employee must have the required skill for the shift
- Employee can’t work overlapping shifts
- Employee needs 10 hours rest between shifts
- Employee can’t work more than one shift per day
- Employee can’t work on days they’re unavailable
- Employee can’t work more than 12 shifts total
Soft constraints (preferences to optimize):
- Avoid scheduling on days the employee marked as “undesired”
- Prefer scheduling on days the employee marked as “desired”
- Balance workload fairly across all employees
Why This is Hard
For even 20 shifts and 10 employees, there are 10^20 possible assignments (100 quintillion). A human can’t evaluate them all. Even a computer trying random assignments would take years.
Optimization algorithms use smart strategies to explore this space efficiently, finding high-quality solutions in seconds.
Understanding the Data Model
Let’s examine the three core classes that model our problem. Open src/employee_scheduling/domain.py:
Domain Model Architecture
This quickstart separates domain models (dataclasses) from API models (Pydantic):
- Domain layer (
domain.pylines 17-39): Pure@dataclassmodels for solver operations - API layer (
domain.pylines 46-75): PydanticBaseModelclasses for REST endpoints - Converters (
converters.py): Translate between the two layers
*This separation provides better performance during solving—Pydantic’s validation overhead becomes expensive when constraints are evaluated millions of times per second. See the architecture article for benchmark comparisons. Note that while benchmarks on small problems show comparable iteration counts between Python and Java, the JPype bridge overhead may compound at larger scales.
The Employee Class
@dataclass
class Employee:
name: Annotated[str, PlanningId]
skills: set[str] = field(default_factory=set)
unavailable_dates: set[date] = field(default_factory=set)
undesired_dates: set[date] = field(default_factory=set)
desired_dates: set[date] = field(default_factory=set)
What it represents: A person who can be assigned to shifts.
Key fields:
name: Unique identifier (thePlanningIdannotation tells SolverForge this is the primary key)skills: What skills this employee possesses (e.g.,{"Doctor", "Cardiology"})unavailable_dates: Days the employee absolutely cannot work (hard constraint)undesired_dates: Days the employee prefers not to work (soft constraint)desired_dates: Days the employee wants to work (soft constraint)
Optimization concept: These availability fields demonstrate hard vs soft constraints. Unavailable is non-negotiable; undesired is a preference the solver will try to honor but may violate if necessary.
The Shift Class (Planning Entity)
@planning_entity
@dataclass
class Shift:
id: Annotated[str, PlanningId]
start: datetime
end: datetime
location: str
required_skill: str
employee: Annotated[Employee | None, PlanningVariable] = None
What it represents: A time slot that needs an employee assignment.
Key fields:
id: Unique identifierstart/end: When the shift occurslocation: Where the work happensrequired_skill: What skill is needed (must match employee’s skills)employee: The assignment decision — this is what the solver optimizes!
Important annotations:
@planning_entity: Tells SolverForge this class contains decisions to makePlanningVariable: Marksemployeeas the decision variable
Optimization concept: This is a planning variable — the value the solver assigns. Each shift starts with employee=None (unassigned). The solver tries different employee assignments, evaluating each according to your constraints.
The EmployeeSchedule Class (Planning Solution)
@planning_solution
@dataclass
class EmployeeSchedule:
employees: Annotated[list[Employee], ProblemFactCollectionProperty, ValueRangeProvider]
shifts: Annotated[list[Shift], PlanningEntityCollectionProperty]
score: Annotated[HardSoftDecimalScore | None, PlanningScore] = None
solver_status: SolverStatus = SolverStatus.NOT_SOLVING
What it represents: The complete problem and its solution.
Key fields:
employees: All available employees (these are the possible values for assignments)shifts: All shifts that need assignment (the planning entities)score: Solution quality metric (calculated by constraints)solver_status: Whether solving is active
Annotations explained:
@planning_solution: Marks this as the top-level problem definitionProblemFactCollectionProperty: Immutable data (doesn’t change during solving)PlanningEntityCollectionProperty: The entities being optimizedValueRangeProvider: Tells the solver which employees can be assigned to shiftsPlanningScore: Where the solver stores the calculated score
Optimization concept: This demonstrates the declarative modeling approach. You describe the problem structure (what can be assigned to what) and the solver handles the search process.
How Optimization Works
Before diving into constraints, let’s understand how the solver finds solutions.
The Search Process (Simplified)
- Start with an initial solution (often random or all unassigned)
- Evaluate the score using your constraint functions
- Make a small change (assign a different employee to one shift)
- Evaluate the new score
- Keep the change if it improves the score (with some controlled randomness)
- Repeat millions of times in seconds
- Return the best solution found
Why This Works: Metaheuristics
Timefold (the engine that powers SolverForge) uses sophisticated metaheuristic algorithms like:
- Tabu Search: Remembers recent moves to avoid cycling
- Simulated Annealing: Occasionally accepts worse solutions to escape local optima
- Late Acceptance: Compares current solution to recent history, not just the immediate previous
These techniques efficiently explore the massive solution space without getting stuck.
The Score: How “Good” is a Solution?
Every solution gets a score with two parts:
0hard/-45soft
- Hard score: Counts hard constraint violations (must be 0 for a valid solution)
- Soft score: Counts soft constraint violations/rewards (higher is better)
Scoring rules:
- Hard score must be 0 or positive (negative = invalid/infeasible)
- Among valid solutions (hard score = 0), highest soft score wins
- Hard score always takes priority over soft score
Optimization concept: This is multi-objective optimization with a lexicographic ordering. We absolutely prioritize hard constraints, then optimize soft ones.
Writing Constraints: The Business Rules
Now the heart of the system. Open src/employee_scheduling/constraints.py.
The Constraint Provider Pattern
All constraints are registered in one function:
@constraint_provider
def define_constraints(constraint_factory: ConstraintFactory):
return [
# Hard constraints
required_skill(constraint_factory),
no_overlapping_shifts(constraint_factory),
at_least_10_hours_between_two_shifts(constraint_factory),
one_shift_per_day(constraint_factory),
unavailable_employee(constraint_factory),
max_shifts_per_employee(constraint_factory),
# Soft constraints
undesired_day_for_employee(constraint_factory),
desired_day_for_employee(constraint_factory),
balance_employee_shift_assignments(constraint_factory),
]
Each constraint is a function returning a Constraint object. Let’s examine them from simple to complex.
Domain Model Methods for Constraints
The Shift class in domain.py includes helper methods that support datetime calculations used by multiple constraints. Following object-oriented design principles, these methods are part of the domain model rather than standalone functions:
def has_required_skill(self) -> bool:
"""Check if assigned employee has the required skill."""
if self.employee is None:
return False
return self.required_skill in self.employee.skills
def is_overlapping_with_date(self, dt: date) -> bool:
"""Check if shift overlaps with a specific date."""
return self.start.date() == dt or self.end.date() == dt
def get_overlapping_duration_in_minutes(self, dt: date) -> int:
"""Calculate how many minutes of a shift fall on a specific date."""
start_date_time = datetime.combine(dt, datetime.min.time())
end_date_time = datetime.combine(dt, datetime.max.time())
# Calculate overlap between date range and shift range
max_start_time = max(start_date_time, self.start)
min_end_time = min(end_date_time, self.end)
minutes = (min_end_time - max_start_time).total_seconds() / 60
return int(max(0, minutes))
These methods encapsulate shift-related logic within the domain model, making constraints more readable and maintainable. They’re particularly important for date-boundary calculations (e.g., a shift spanning midnight).
Hard Constraint: Required Skill
Business rule: “An employee assigned to a shift must have the required skill.”
def required_skill(constraint_factory: ConstraintFactory):
return (
constraint_factory.for_each(Shift)
.filter(lambda shift: not shift.has_required_skill())
.penalize(HardSoftDecimalScore.ONE_HARD)
.as_constraint("Missing required skill")
)
How to read this:
for_each(Shift): Consider every shift in the schedule.filter(...): Keep only shifts where the employee lacks the required skill.penalize(ONE_HARD): Each violation subtracts 1 from the hard score.as_constraint(...): Give it a name for debugging
Optimization concept: This is a unary constraint — it examines one entity at a time. The filter identifies violations and the penalty quantifies the impact.
Note: There’s no null check for shift.employee because constraints are only evaluated on complete assignments during the scoring phase.
Hard Constraint: No Overlapping Shifts
Business rule: “An employee can’t work two shifts that overlap in time.”
def no_overlapping_shifts(constraint_factory: ConstraintFactory):
return (
constraint_factory.for_each_unique_pair(
Shift,
Joiners.equal(lambda shift: shift.employee.name),
Joiners.overlapping(lambda shift: shift.start, lambda shift: shift.end),
)
.penalize(HardSoftDecimalScore.ONE_HARD, get_minute_overlap)
.as_constraint("Overlapping shift")
)
How to read this:
for_each_unique_pair(Shift, ...): Create pairs of shiftsJoiners.equal(lambda shift: shift.employee.name): Only pair shifts assigned to the same employeeJoiners.overlapping(...): Only pair shifts that overlap in time.penalize(ONE_HARD, get_minute_overlap): Penalize by the number of overlapping minutes
Optimization concept: This is a binary constraint — it examines pairs of entities. The for_each_unique_pair ensures we don’t count each violation twice (e.g., comparing shift A to B and B to A).
Helper function:
def get_minute_overlap(shift1: Shift, shift2: Shift) -> int:
return (
min(shift1.end, shift2.end) - max(shift1.start, shift2.start)
).total_seconds() // 60
Why penalize by minutes? This creates a graded penalty. A 5-minute overlap is less bad than a 5-hour overlap, giving the solver better guidance.
Hard Constraint: Rest Between Shifts
Business rule: “Employees need at least 10 hours rest between shifts.”
def at_least_10_hours_between_two_shifts(constraint_factory: ConstraintFactory):
return (
constraint_factory.for_each(Shift)
.join(
Shift,
Joiners.equal(lambda shift: shift.employee.name),
Joiners.less_than_or_equal(
lambda shift: shift.end, lambda shift: shift.start
),
)
.filter(
lambda first_shift, second_shift: (
second_shift.start - first_shift.end
).total_seconds() // (60 * 60) < 10
)
.penalize(
HardSoftDecimalScore.ONE_HARD,
lambda first_shift, second_shift: 600 - (
(second_shift.start - first_shift.end).total_seconds() // 60
),
)
.as_constraint("At least 10 hours between 2 shifts")
)
How to read this:
for_each(Shift): Start with all shifts.join(Shift, ...): Pair with other shiftsJoiners.equal(...): Same employeeJoiners.less_than_or_equal(...): First shift ends before or when second starts (ensures ordering).filter(...): Keep only pairs with less than 10 hours gap.penalize(...): Penalize by600 - actual_minutes(the deficit from required 10 hours)
Optimization concept: The penalty function 600 - actual_minutes creates incremental guidance. 9 hours rest (penalty 60) is better than 5 hours rest (penalty 300), helping the solver navigate toward feasibility.
Hard Constraint: One Shift Per Day
Business rule: “Employees can work at most one shift per calendar day.”
def one_shift_per_day(constraint_factory: ConstraintFactory):
return (
constraint_factory.for_each_unique_pair(
Shift,
Joiners.equal(lambda shift: shift.employee.name),
Joiners.equal(lambda shift: shift.start.date()),
)
.penalize(HardSoftDecimalScore.ONE_HARD)
.as_constraint("Max one shift per day")
)
How to read this:
for_each_unique_pair(Shift, ...): Create pairs of shifts- First joiner: Same employee
- Second joiner: Same date (
shift.start.date()extracts calendar day) - Each pair found is a violation
Hard Constraint: Unavailable Dates
Business rule: “Employees cannot work on days they marked as unavailable.”
def unavailable_employee(constraint_factory: ConstraintFactory):
return (
constraint_factory.for_each(Shift)
.join(
Employee,
Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
)
.flatten_last(lambda employee: employee.unavailable_dates)
.filter(lambda shift, unavailable_date: shift.is_overlapping_with_date(unavailable_date))
.penalize(
HardSoftDecimalScore.ONE_HARD,
lambda shift, unavailable_date: shift.get_overlapping_duration_in_minutes(unavailable_date),
)
.as_constraint("Unavailable employee")
)
How to read this:
for_each(Shift): All shifts.join(Employee, ...): Join with the assigned employee.flatten_last(lambda employee: employee.unavailable_dates): Expand each employee’s unavailable_dates set.filter(...): Keep only when shift overlaps the unavailable date.penalize(...): Penalize by overlapping duration in minutes
Optimization concept: The flatten_last operation demonstrates constraint streaming with collections. We iterate over each date in the employee’s unavailable set, creating (shift, date) pairs to check. The shift.is_overlapping_with_date() and shift.get_overlapping_duration_in_minutes() methods are defined on the Shift domain model class.
Soft Constraint: Undesired Days
Business rule: “Prefer not to schedule employees on days they marked as undesired.”
def undesired_day_for_employee(constraint_factory: ConstraintFactory):
return (
constraint_factory.for_each(Shift)
.join(
Employee,
Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
)
.flatten_last(lambda employee: employee.undesired_dates)
.filter(lambda shift, undesired_date: shift.is_overlapping_with_date(undesired_date))
.penalize(
HardSoftDecimalScore.ONE_SOFT,
lambda shift, undesired_date: shift.get_overlapping_duration_in_minutes(undesired_date),
)
.as_constraint("Undesired day for employee")
)
Key difference from hard constraints: Uses ONE_SOFT instead of ONE_HARD.
Optimization concept: The solver will try to avoid undesired days but may violate this if necessary to satisfy hard constraints or achieve better overall soft score.
Soft Constraint: Desired Days (Reward)
Business rule: “Prefer to schedule employees on days they marked as desired.”
def desired_day_for_employee(constraint_factory: ConstraintFactory):
return (
constraint_factory.for_each(Shift)
.join(
Employee,
Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
)
.flatten_last(lambda employee: employee.desired_dates)
.filter(lambda shift, desired_date: shift.is_overlapping_with_date(desired_date))
.reward(
HardSoftDecimalScore.ONE_SOFT,
lambda shift, desired_date: shift.get_overlapping_duration_in_minutes(desired_date),
)
.as_constraint("Desired day for employee")
)
Key difference: Uses .reward() instead of .penalize().
Optimization concept: Rewards increase the score instead of decreasing it. This constraint actively pulls the solution toward desired assignments.
Soft Constraint: Load Balancing
Business rule: “Distribute shifts fairly across employees.”
def balance_employee_shift_assignments(constraint_factory: ConstraintFactory):
return (
constraint_factory.for_each(Shift)
.group_by(lambda shift: shift.employee, ConstraintCollectors.count())
.complement(Employee, lambda e: 0)
.group_by(
ConstraintCollectors.load_balance(
lambda employee, shift_count: employee,
lambda employee, shift_count: shift_count,
)
)
.penalize_decimal(
HardSoftDecimalScore.ONE_SOFT,
lambda load_balance: load_balance.unfairness(),
)
.as_constraint("Balance employee shift assignments")
)
How to read this:
for_each(Shift): All shifts.group_by(..., ConstraintCollectors.count()): Count shifts per employee.complement(Employee, lambda e: 0): Include employees with 0 shifts.group_by(ConstraintCollectors.load_balance(...)): Calculate fairness metric.penalize_decimal(..., unfairness()): Penalize by the unfairness amount
Optimization concept: This uses a sophisticated load balancing collector that calculates variance/unfairness in workload distribution. It’s more nuanced than simple quadratic penalties — it measures how far the distribution is from perfectly balanced.
Why complement? Without it, employees with zero shifts wouldn’t appear in the grouping, skewing the fairness calculation.
The Solver Engine
Now let’s see how the solver is configured. Open src/employee_scheduling/solver.py:
solver_config = SolverConfig(
solution_class=EmployeeSchedule,
entity_class_list=[Shift],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=define_constraints
),
termination_config=TerminationConfig(spent_limit=Duration(seconds=30)),
)
solver_manager = SolverManager.create(SolverFactory.create(solver_config))
solution_manager = SolutionManager.create(solver_manager)
Configuration Breakdown
solution_class: Your planning solution class (EmployeeSchedule)
entity_class_list: Planning entities to optimize ([Shift])
score_director_factory_config: Contains the constraint provider function
- Note: This is nested inside
ScoreDirectorFactoryConfig, not directly inSolverConfig
termination_config: When to stop solving
spent_limit=Duration(seconds=30): Stop after 30 seconds
SolverManager: Asynchronous Solving
SolverManager handles solving in the background without blocking your API:
# Start solving (non-blocking)
solver_manager.solve_and_listen(job_id, schedule, callback_function)
# Check status
status = solver_manager.get_status(job_id)
# Get current best solution
solution = solver_manager.get_solution(job_id)
# Stop early
solver_manager.terminate_early(job_id)
Optimization concept: Real-world problems may take minutes to hours. Anytime algorithms like metaheuristics continuously improve solutions over time, so you can stop whenever you’re satisfied with the quality.
Solving Timeline
Small problems (10-20 shifts, 5-10 employees):
- Initial valid solution: < 1 second
- Good solution: 5-10 seconds
- High-quality: 30 seconds
Medium problems (50-100 shifts, 20-30 employees):
- Initial valid solution: 1-5 seconds
- Good solution: 30-60 seconds
- High-quality: 5-10 minutes
Factors affecting speed:
- Number of employees × shifts (search space size)
- Constraint complexity
- How “tight” constraints are (fewer valid solutions = harder)
Web Interface and API
REST API Endpoints
Open src/employee_scheduling/rest_api.py to see the API:
GET /demo-data
Returns available demo datasets:
["SMALL", "LARGE"]
GET /demo-data/{dataset_id}
Generates and returns sample data:
{
"employees": [
{
"name": "Amy Cole",
"skills": ["Doctor", "Cardiology"],
"unavailableDates": ["2025-11-25"],
"undesiredDates": ["2025-11-26"],
"desiredDates": ["2025-11-27"]
}
],
"shifts": [
{
"id": "0",
"start": "2025-11-25T06:00:00",
"end": "2025-11-25T14:00:00",
"location": "Ambulatory care",
"requiredSkill": "Doctor",
"employee": null
}
]
}
Note: Field names use camelCase in JSON (REST API convention) but snake_case in Python (domain model). The converters.py handles this translation.
POST /schedules
Submit a schedule to solve:
Request body: Same format as demo-data response
Response: Job ID as plain text
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
Implementation:
@app.post("/schedules")
async def solve_timetable(schedule_model: EmployeeScheduleModel) -> str:
job_id = str(uuid4())
schedule = model_to_schedule(schedule_model)
data_sets[job_id] = schedule
solver_manager.solve_and_listen(
job_id,
schedule,
lambda solution: update_schedule(job_id, solution)
)
return job_id
Key detail: Uses solve_and_listen() with a callback that updates the stored solution in real-time as solving progresses.
GET /schedules/{problem_id}
Check solving status and get current solution:
Response (while solving):
{
"employees": [...],
"shifts": [...],
"score": "0hard/-45soft",
"solverStatus": "SOLVING_ACTIVE"
}
Response (finished):
{
"employees": [...],
"shifts": [...],
"score": "0hard/-12soft",
"solverStatus": "NOT_SOLVING"
}
DELETE /schedules/{problem_id}
Stop solving early and return best solution found so far:
@app.delete("/schedules/{problem_id}")
async def stop_solving(problem_id: str) -> None:
solver_manager.terminate_early(problem_id)
Web UI Flow
The static/app.js implements this polling workflow:
- User opens page → Load demo data (
GET /demo-data/SMALL) - Display employees and shifts in timeline visualization
- User clicks “Solve” →
POST /schedules(get job ID back) - Poll
GET /schedules/{id}every 2 seconds - Update UI with latest assignments in real-time
- When
solverStatus === "NOT_SOLVING"→ Stop polling - Display final score and solution
Visual feedback: The UI uses vis-timeline library to show:
- Shifts color-coded by availability (red=unavailable, orange=undesired, green=desired, blue=normal)
- Skills color-coded (red=missing skill, green=has skill)
- Two views: by employee and by location
Making Your First Customization
The quickstart includes a cardinality constraint that demonstrates a common pattern. Let’s understand how it works and then learn how to create similar constraints.
Understanding the Max Shifts Constraint
The codebase includes max_shifts_per_employee which limits workload imbalance:
Business rule: “No employee can work more than 12 shifts in the schedule period.”
This is a hard constraint (must be satisfied).
The Constraint Implementation
This constraint is already in src/employee_scheduling/constraints.py:
def max_shifts_per_employee(constraint_factory: ConstraintFactory):
"""
Hard constraint: No employee can have more than 12 shifts.
The limit of 12 is chosen based on the demo data dimensions:
- SMALL dataset: 139 shifts / 15 employees = ~9.3 average
- This provides headroom while preventing extreme imbalance
Note: A limit that's too low (e.g., 5) would make the problem infeasible.
Always ensure your constraints are compatible with your data dimensions.
"""
return (
constraint_factory.for_each(Shift)
.group_by(lambda shift: shift.employee, ConstraintCollectors.count())
.filter(lambda employee, shift_count: shift_count > 12)
.penalize(
HardSoftDecimalScore.ONE_HARD,
lambda employee, shift_count: shift_count - 12,
)
.as_constraint("Max 12 shifts per employee")
)
How this works:
- Group shifts by employee and count them
- Filter to employees with more than 12 shifts
- Penalize by the excess amount (13 shifts = penalty 1, 14 shifts = penalty 2, etc.)
Why 12? The demo data has 139 shifts and 15 employees (~9.3 shifts per employee on average). A limit that’s too low (e.g., 5) would make the problem infeasible — there simply aren’t enough employees to cover all shifts. Always ensure your constraints are compatible with your problem’s dimensions.
How It’s Registered
The constraint is registered in define_constraints() along with the other constraints:
@constraint_provider
def define_constraints(constraint_factory: ConstraintFactory):
return [
# Hard constraints
required_skill(constraint_factory),
no_overlapping_shifts(constraint_factory),
at_least_10_hours_between_two_shifts(constraint_factory),
one_shift_per_day(constraint_factory),
unavailable_employee(constraint_factory),
max_shifts_per_employee(constraint_factory), # ← Cardinality constraint
# Soft constraints
undesired_day_for_employee(constraint_factory),
desired_day_for_employee(constraint_factory),
balance_employee_shift_assignments(constraint_factory),
]
Experimenting With It
Try modifying the constraint to see its effect:
- Change the limit from 12 to 8 in
constraints.py - Restart the server:
python -m employee_scheduling.rest_api - Load demo data and click “Solve”
- Observe how the constraint affects the solution
Note: A very low limit (e.g., 5) will make the problem infeasible.
Why Unit Testing Constraints Matters
The quickstart includes unit tests in tests/test_constraints.py using ConstraintVerifier. Run them with:
pytest tests/test_constraints.py -v
Testing catches critical issues early. When we initially implemented this constraint with a limit of 5, the feasibility test (test_feasible.py) failed — the solver couldn’t find a valid solution because there weren’t enough employees to cover all shifts within that limit. Without tests, this would have silently broken the scheduling system. Always test new constraints — a typo in a filter or an overly restrictive limit can make your problem unsolvable.
Understanding What You Did
You just implemented a cardinality constraint — limiting the count of something. This pattern is extremely common in scheduling:
- Maximum hours per week
- Minimum shifts per employee
- Exact number of nurses per shift
The pattern is always:
- Group by what you’re counting
- Collect the count
- Filter by your limit
- Penalize/reward appropriately
Advanced Constraint Patterns
Pattern 1: Weighted Penalties
Scenario: Some skills are harder to staff — penalize their absence more heavily.
def preferred_skill_coverage(constraint_factory: ConstraintFactory):
"""
Soft constraint: Prefer specialized skills when available.
"""
SPECIALTY_SKILLS = {"Cardiology", "Anaesthetics", "Radiology"}
return (
constraint_factory.for_each(Shift)
.filter(lambda shift: shift.required_skill in SPECIALTY_SKILLS)
.filter(lambda shift: shift.required_skill in shift.employee.skills)
.reward(
HardSoftDecimalScore.of_soft(10), # 10x normal reward
)
.as_constraint("Preferred specialty coverage")
)
Optimization concept: Weighted constraints let you express relative importance. This rewards specialty matches 10 times more than standard matches.
Pattern 2: Conditional Constraints
Scenario: Night shifts (after 6 PM) require two employees at the same location.
def night_shift_minimum_staff(constraint_factory: ConstraintFactory):
"""
Hard constraint: Night shifts need at least 2 employees per location.
"""
def is_night_shift(shift: Shift) -> bool:
return shift.start.hour >= 18 # 6 PM or later
return (
constraint_factory.for_each(Shift)
.filter(is_night_shift)
.group_by(
lambda shift: (shift.start, shift.location),
ConstraintCollectors.count()
)
.filter(lambda timeslot_location, count: count < 2)
.penalize(
HardSoftDecimalScore.ONE_HARD,
lambda timeslot_location, count: 2 - count
)
.as_constraint("Night shift minimum 2 staff")
)
Pattern 3: Employee Pairing (Incompatibility)
Scenario: Certain employees shouldn’t work the same shift.
First, add the field to domain.py:
@dataclass
class Employee:
name: Annotated[str, PlanningId]
skills: set[str] = field(default_factory=set)
# ... existing fields ...
incompatible_with: set[str] = field(default_factory=set) # employee names
Then the constraint:
def avoid_incompatible_pairs(constraint_factory: ConstraintFactory):
"""
Hard constraint: Incompatible employees can't work overlapping shifts.
"""
return (
constraint_factory.for_each(Shift)
.join(
Shift,
Joiners.equal(lambda shift: shift.location),
Joiners.overlapping(lambda shift: shift.start, lambda shift: shift.end),
)
.filter(
lambda shift1, shift2:
shift2.employee.name in shift1.employee.incompatible_with
)
.penalize(HardSoftDecimalScore.ONE_HARD)
.as_constraint("Avoid incompatible pairs")
)
Pattern 4: Time-Based Accumulation
Scenario: Limit total hours worked per week.
def max_hours_per_week(constraint_factory: ConstraintFactory):
"""
Hard constraint: Maximum 40 hours per employee per week.
"""
def get_shift_hours(shift: Shift) -> float:
return (shift.end - shift.start).total_seconds() / 3600
def get_week(shift: Shift) -> int:
return shift.start.isocalendar()[1] # ISO week number
return (
constraint_factory.for_each(Shift)
.group_by(
lambda shift: (shift.employee, get_week(shift)),
ConstraintCollectors.sum(get_shift_hours)
)
.filter(lambda employee_week, total_hours: total_hours > 40)
.penalize(
HardSoftDecimalScore.ONE_HARD,
lambda employee_week, total_hours: int(total_hours - 40)
)
.as_constraint("Max 40 hours per week")
)
Optimization concept: This uses temporal aggregation — grouping by time periods (weeks) and summing durations. Common in workforce scheduling.
Testing and Validation
Unit Testing Constraints
Best practice: Test each constraint in isolation.
Create tests/test_my_constraints.py:
from datetime import datetime, date
from employee_scheduling.domain import Employee, Shift, EmployeeSchedule
from employee_scheduling.solver import solver_config
from solverforge_legacy.solver import SolverFactory
def test_max_shifts_constraint_violation():
"""Test that exceeding 5 shifts creates a hard constraint violation."""
employee = Employee(
name="Test Employee",
skills={"Doctor"}
)
# Create 6 shifts assigned to same employee
shifts = []
for i in range(6):
shifts.append(Shift(
id=str(i),
start=datetime(2025, 11, 25 + i, 9, 0),
end=datetime(2025, 11, 25 + i, 17, 0),
location="Test Location",
required_skill="Doctor",
employee=employee
))
schedule = EmployeeSchedule(
employees=[employee],
shifts=shifts
)
# Score the solution
solver_factory = SolverFactory.create(solver_config)
score_director = solver_factory.get_score_director_factory().build_score_director()
score_director.set_working_solution(schedule)
score = score_director.calculate_score()
# Verify hard constraint violation
assert score.hard_score == -1, f"Expected -1 hard score, got {score.hard_score}"
def test_max_shifts_constraint_satisfied():
"""Test that 5 or fewer shifts doesn't violate constraint."""
employee = Employee(
name="Test Employee",
skills={"Doctor"}
)
# Create only 5 shifts
shifts = []
for i in range(5):
shifts.append(Shift(
id=str(i),
start=datetime(2025, 11, 25 + i, 9, 0),
end=datetime(2025, 11, 25 + i, 17, 0),
location="Test Location",
required_skill="Doctor",
employee=employee
))
schedule = EmployeeSchedule(
employees=[employee],
shifts=shifts
)
solver_factory = SolverFactory.create(solver_config)
score_director = solver_factory.get_score_director_factory().build_score_director()
score_director.set_working_solution(schedule)
score = score_director.calculate_score()
# No violation from this constraint (may have soft penalties from balancing, etc.)
assert score.hard_score >= -0, f"Expected non-negative hard score, got {score.hard_score}"
Run with:
pytest tests/test_my_constraints.py -v
Integration Testing: Full Solve
Test the complete solving cycle in tests/test_feasible.py:
import time
from employee_scheduling.demo_data import DemoData, generate_demo_data
from employee_scheduling.solver import solver_manager
from solverforge_legacy.solver import SolverStatus
def test_solve_small_dataset():
"""Test that solver finds a feasible solution for small dataset."""
# Generate problem
schedule = generate_demo_data(DemoData.SMALL)
# Verify initially unassigned
assert all(shift.employee is None for shift in schedule.shifts)
# Solve
job_id = "test-job"
solver_manager.solve(job_id, schedule)
# Wait for completion (with timeout)
timeout_seconds = 60
start_time = time.time()
while solver_manager.get_solver_status(job_id) != SolverStatus.NOT_SOLVING:
if time.time() - start_time > timeout_seconds:
solver_manager.terminate_early(job_id)
break
time.sleep(1)
# Get solution
solution = solver_manager.get_solution(job_id)
# Verify all shifts assigned
assert all(shift.employee is not None for shift in solution.shifts), \
"Not all shifts were assigned"
# Verify feasible (hard score = 0)
assert solution.score.hard_score == 0, \
f"Solution is infeasible with hard score {solution.score.hard_score}"
print(f"Final score: {solution.score}")
Manual Testing via UI
- Start the application:
python -m employee_scheduling.rest_api - Open browser console (F12) to see API calls
- Load “SMALL” demo data
- Verify data displays correctly (employees with skills, shifts unassigned)
- Click “Solve” and watch:
- Score improving in real-time
- Shifts getting assigned (colored by availability)
- Final hard score reaches 0
- Manually verify constraint satisfaction:
- Check that assigned employees have required skills (green badges)
- Verify no overlapping shifts (timeline shouldn’t show overlaps)
- Confirm unavailable days are respected (no shifts on red-highlighted dates)
Quick Reference
File Locations
| Need to… | Edit this file |
|---|---|
| Add/change business rule | src/employee_scheduling/constraints.py |
| Add field to Employee | src/employee_scheduling/domain.py + converters.py |
| Add field to Shift | src/employee_scheduling/domain.py + converters.py |
| Change solve time | src/employee_scheduling/solver.py |
| Add REST endpoint | src/employee_scheduling/rest_api.py |
| Change demo data | src/employee_scheduling/demo_data.py |
| Change UI | static/index.html, static/app.js |
Common Constraint Patterns
Unary constraint (examine one entity):
constraint_factory.for_each(Shift)
.filter(lambda shift: # condition)
.penalize(HardSoftDecimalScore.ONE_HARD)
Binary constraint (examine pairs):
constraint_factory.for_each_unique_pair(
Shift,
Joiners.equal(lambda shift: shift.employee.name)
)
.penalize(HardSoftDecimalScore.ONE_HARD)
Grouping and counting:
constraint_factory.for_each(Shift)
.group_by(
lambda shift: shift.employee,
ConstraintCollectors.count()
)
.filter(lambda employee, count: count > MAX)
.penalize(...)
Reward instead of penalize:
.reward(HardSoftDecimalScore.ONE_SOFT)
Variable penalty:
.penalize(
HardSoftDecimalScore.ONE_HARD,
lambda shift: calculate_penalty_amount(shift)
)
Working with collections (flatten):
constraint_factory.for_each(Shift)
.join(Employee, Joiners.equal(...))
.flatten_last(lambda employee: employee.unavailable_dates)
.filter(...)
Common Joiners
| Joiner | Purpose |
|---|---|
Joiners.equal(lambda x: x.field) | Match entities with same field value |
Joiners.less_than(lambda x: x.field) | First entity’s field < second’s (ensures ordering) |
Joiners.overlapping(start, end) | Time intervals overlap |
Debugging Tips
Enable verbose logging:
import logging
logging.basicConfig(level=logging.DEBUG)
Test constraint in isolation:
# Create minimal test case with just the constraint you're debugging
schedule = EmployeeSchedule(
employees=[test_employee],
shifts=[test_shift]
)
solver_factory = SolverFactory.create(solver_config)
score_director = solver_factory.get_score_director_factory().build_score_director()
score_director.set_working_solution(schedule)
score = score_director.calculate_score()
print(f"Score: {score}")
Check constraint matches: Add print statements (remove in production):
.filter(lambda shift: (
print(f"Checking shift {shift.id}") or # Debug print
shift.required_skill not in shift.employee.skills
))
Common Gotchas
Forgot to register constraint in
define_constraints()return list- Symptom: Constraint not enforced
Using wrong Joiner
Joiners.equalwhen you needJoiners.less_than- Symptom: Pairs counted twice or constraint not working
Expensive operations in constraint functions
- Database/API calls in filters
- Symptom: Solving extremely slow
Score sign confusion
- Higher soft score is better (not worse!)
- Hard score must be ≥ 0 for feasible solution
Field name mismatch
- Guide said
skill_set, actual isskills - Guide said
employee_list, actual isemployees
- Guide said
Additional Resources
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.