This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Getting Started

Quickstart Guides — repository layout, prerequisites, and how to run examples locally.

This page introduces the SolverForge Python quickstarts: what is included, how the repository is organised, and simple steps to try the examples locally.

SolverForge Quickstarts provide Python examples demonstrating common constraint-solver use cases and patterns. They are fully Timefold-compatible and focus on practical, performant Python implementations.

This page covers:

  • Repository layout and quickstart variants
  • Prerequisites and installation notes
  • How to run an example locally
  • Where to find benchmarks, technical notes and individual quickstart READMEs

Repository layout

The repository is organised so you can choose between pedagogical, reference implementations and optimized, performance-minded variants:

  • legacy/ — Original quickstarts that use unified Pydantic models. Great for learning and understanding the domain modelling approach.
  • fast/ — Refactored quickstarts that minimize runtime overhead by constraining Pydantic to the API boundary and using lighter-weight models during solver moves.
  • benchmarks/ — Benchmarks, results and a short performance report comparing implementations and use cases.

Common fast quickstarts available now:

  • fast/meeting-scheduling-fast
  • fast/vehicle-routing-fast
  • fast/employee-scheduling-fast

Each use case folder includes a README describing how to run the example, expected inputs, and any implementation-specific details.

Prerequisites

Typical requirements (may vary per quickstart):

  • Python 3.8+ (use a virtual environment)
  • pip to install dependencies
  • Optional: Docker if you prefer containerised execution

Some examples expose a small FastAPI UI or HTTP API and will list FastAPI and related packages in their requirements.txt or pyproject.toml.

Installation

  1. Clone or download the SolverForge quickstarts repository.

  2. Create and activate a virtual environment:

    • Unix/macOS:
      • python -m venv .venv
      • source .venv/bin/activate
    • Windows:
      • python -m venv .venv
      • .\\.venv\\Scripts\\activate
  3. Install dependencies from the chosen quickstart directory:

    • pip install -r requirements.txt
    • Or follow the quickstart’s pyproject.toml instructions if provided.

Each quickstart README documents any extra dependencies or optional tooling.

Setup

  • Inspect the quickstart folder for example data, configuration files, and environment variables.
  • If the quickstart includes Docker assets, follow the README for Docker or docker-compose instructions.
  • Confirm any required ports or external resources before starting the example.

Try it out!

Most quickstarts offer one or both run modes:

  • A minimal FastAPI service that serves a tiny UI and HTTP endpoints.
  • A CLI script that runs the solver on example data and outputs results.

To try a quick example:

  1. Open the quickstart folder of interest (for example fast/meeting-scheduling-fast).
  2. Follow the run instructions in that folder’s README. Common commands are:
    • python -m <module> or uvicorn for FastAPI-based examples.
    • python run_demo.py or similar CLI entrypoints described in the README.

Check these README files for concrete run commands:

  • legacy/vehicle-routing/README.MD
  • fast/vehicle-routing-fast/README.MD
  • fast/meeting-scheduling-fast/README.adoc
  • fast/employee-scheduling-fast/README.MD

Benchmarks & performance

Performance-focused work and benchmark artifacts live in the benchmarks/ folder:

  • benchmarks/results_meeting-scheduling.md
  • benchmarks/results_vehicle-routing.md
  • benchmarks/report.md

The fast/ refactors prioritise reducing runtime overhead (limiting Pydantic to the API boundary, using compact domain models, etc.) to close the performance gap with Java/Kotlin implementations.

Where to read more

  • Start at the repository top-level README for an overview and the full use-case list.
  • Read the individual quickstart READMEs for run instructions, configuration and design notes.
  • Consult benchmarks/ for performance comparisons and technical rationale.

This repository derives from prior quickstarts and carries permissive licensing details documented in the top-level README and LICENSE files. Refer to those files for full copyright and licensing information.

1 - Employee Scheduling

A comprehensive quickstart guide to understanding and building intelligent employee scheduling with SolverForge

A comprehensive quickstart guide to understanding and building intelligent employee scheduling with SolverForge. Learn optimization concepts while exploring a working codebase.


Table of Contents

  1. Introduction
  2. Getting Started
  3. The Problem We’re Solving
  4. Understanding the Data Model
  5. How Optimization Works
  6. Writing Constraints: The Business Rules
  7. The Solver Engine
  8. Web Interface and API
  9. Making Your First Customization
  10. Advanced Constraint Patterns
  11. Testing and Validation
  12. 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

  1. Download and navigate to the project directory:

    git clone https://github.com/SolverForge/solverforge-quickstarts
    cd ./solverforge-quickstarts/fast/employee-scheduling-fast
    
  2. Install dependencies:

    pip install -r requirements.txt
    
  3. Start the server:

    python -m employee_scheduling.rest_api
    
  4. Open 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.py lines 17-39): Pure @dataclass models for solver operations
  • API layer (domain.py lines 46-75): Pydantic BaseModel classes 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 (the PlanningId annotation 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 identifier
  • start/end: When the shift occurs
  • location: Where the work happens
  • required_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 make
  • PlanningVariable: Marks employee as 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 definition
  • ProblemFactCollectionProperty: Immutable data (doesn’t change during solving)
  • PlanningEntityCollectionProperty: The entities being optimized
  • ValueRangeProvider: Tells the solver which employees can be assigned to shifts
  • PlanningScore: 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)

  1. Start with an initial solution (often random or all unassigned)
  2. Evaluate the score using your constraint functions
  3. Make a small change (assign a different employee to one shift)
  4. Evaluate the new score
  5. Keep the change if it improves the score (with some controlled randomness)
  6. Repeat millions of times in seconds
  7. 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:

  1. for_each(Shift): Consider every shift in the schedule
  2. .filter(...): Keep only shifts where the employee lacks the required skill
  3. .penalize(ONE_HARD): Each violation subtracts 1 from the hard score
  4. .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:

  1. for_each_unique_pair(Shift, ...): Create pairs of shifts
  2. Joiners.equal(lambda shift: shift.employee.name): Only pair shifts assigned to the same employee
  3. Joiners.overlapping(...): Only pair shifts that overlap in time
  4. .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:

  1. for_each(Shift): Start with all shifts
  2. .join(Shift, ...): Pair with other shifts
  3. Joiners.equal(...): Same employee
  4. Joiners.less_than_or_equal(...): First shift ends before or when second starts (ensures ordering)
  5. .filter(...): Keep only pairs with less than 10 hours gap
  6. .penalize(...): Penalize by 600 - 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:

  1. for_each_unique_pair(Shift, ...): Create pairs of shifts
  2. First joiner: Same employee
  3. Second joiner: Same date (shift.start.date() extracts calendar day)
  4. 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:

  1. for_each(Shift): All shifts
  2. .join(Employee, ...): Join with the assigned employee
  3. .flatten_last(lambda employee: employee.unavailable_dates): Expand each employee’s unavailable_dates set
  4. .filter(...): Keep only when shift overlaps the unavailable date
  5. .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:

  1. for_each(Shift): All shifts
  2. .group_by(..., ConstraintCollectors.count()): Count shifts per employee
  3. .complement(Employee, lambda e: 0): Include employees with 0 shifts
  4. .group_by(ConstraintCollectors.load_balance(...)): Calculate fairness metric
  5. .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 in SolverConfig

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:

  1. User opens page → Load demo data (GET /demo-data/SMALL)
  2. Display employees and shifts in timeline visualization
  3. User clicks “Solve”POST /schedules (get job ID back)
  4. Poll GET /schedules/{id} every 2 seconds
  5. Update UI with latest assignments in real-time
  6. When solverStatus === "NOT_SOLVING" → Stop polling
  7. 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:

  1. Group shifts by employee and count them
  2. Filter to employees with more than 12 shifts
  3. 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:

  1. Change the limit from 12 to 8 in constraints.py
  2. Restart the server: python -m employee_scheduling.rest_api
  3. Load demo data and click “Solve”
  4. 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:

  1. Group by what you’re counting
  2. Collect the count
  3. Filter by your limit
  4. 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

  1. Start the application: python -m employee_scheduling.rest_api
  2. Open browser console (F12) to see API calls
  3. Load “SMALL” demo data
  4. Verify data displays correctly (employees with skills, shifts unassigned)
  5. Click “Solve” and watch:
    • Score improving in real-time
    • Shifts getting assigned (colored by availability)
    • Final hard score reaches 0
  6. 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 rulesrc/employee_scheduling/constraints.py
Add field to Employeesrc/employee_scheduling/domain.py + converters.py
Add field to Shiftsrc/employee_scheduling/domain.py + converters.py
Change solve timesrc/employee_scheduling/solver.py
Add REST endpointsrc/employee_scheduling/rest_api.py
Change demo datasrc/employee_scheduling/demo_data.py
Change UIstatic/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

JoinerPurpose
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

  1. Forgot to register constraint in define_constraints() return list

    • Symptom: Constraint not enforced
  2. Using wrong Joiner

    • Joiners.equal when you need Joiners.less_than
    • Symptom: Pairs counted twice or constraint not working
  3. Expensive operations in constraint functions

    • Database/API calls in filters
    • Symptom: Solving extremely slow
  4. Score sign confusion

    • Higher soft score is better (not worse!)
    • Hard score must be ≥ 0 for feasible solution
  5. Field name mismatch

    • Guide said skill_set, actual is skills
    • Guide said employee_list, actual is employees

Additional Resources