Skip to content

Getting started (Python)

This guide builds a small staff schedule from scratch: a week of shifts, four people, three hard rules and one soft one. By the end you will have modelled a problem, written its rules as constraint streams, solved it, and read why the answer scores as it does.

The code blocks below, run in order, are a complete program. A test runs exactly that program and checks its answer, so what you read here runs.

1. Install

pip install heusor

Python 3.11 or newer, on Linux x86-64, macOS on Apple silicon or Windows x86-64. One import brings in what a typical program uses:

from dataclasses import dataclass, field
from typing import Annotated

import heusor
from heusor import (
    ConstraintCollectors, ConstraintFactory, HardSoftScore, Joiners,
    PlanningVariable, constraint_provider, planning_entity, planning_solution,
    problem_fact,
)

2. Problem facts — what the solver reads

A problem fact is input the solver never changes. Here: the employees. Mark the class @problem_fact; it is an ordinary dataclass.

@problem_fact
@dataclass
class Employee:
    name: str
    skills: set[str] = field(default_factory=set)
    unavailable_days: set[int] = field(default_factory=set)

Fields may be int, bool, str, an Enum, date, time, datetime, timedelta, another @problem_fact class, or a list / set of those. Use integers rather than float: keep money in cents and durations in minutes.

3. The planning entity — what the solver changes

A planning entity holds a decision. Each shift needs one person, so the entity is the shift, and the decision is which employee:

@planning_entity
@dataclass
class Shift:
    day: int
    required_skill: str
    employee: Annotated[Employee | None, PlanningVariable] = None
  • Annotated[Employee | None, PlanningVariable] marks the decision. It is None until the solver assigns it.
  • The solver picks the value from the solution's list of Employees (next section). The variable holds the employee itself, not an index.

4. The planning solution — the whole problem

The planning solution holds everything: the facts, the entities, and a field for the score.

@planning_solution
@dataclass
class Schedule:
    employees: list[Employee]
    shifts: list[Shift]
    score: HardSoftScore | None = None

HardSoftScore has two levels. Hard rules must hold for the answer to be usable; soft rules say which usable answer is better. A solution is feasible when its hard score is 0.

5. Constraints — the rules

Rules are constraint streams. A stream starts from every shift, narrows to the ones breaking a rule, and penalises each match. The engine keeps every stream up to date incrementally: when the solver tries moving one shift, only the matches involving that shift are recomputed.

The streams are returned from a function marked @constraint_provider. Its lambdas are read from their source and compiled once, when the problem is built; no Python runs while the solver searches.

@constraint_provider
def constraints(f: ConstraintFactory):
    return [
        required_skill(f),
        unavailable_day(f),
        one_shift_per_day(f),
        fair_workload(f),
    ]

A rule about one entity: filter, then penalise

def required_skill(f: ConstraintFactory):
    # Hard: whoever works a shift has the skill it requires.
    return (
        f.for_each(Shift)
        .filter(lambda shift: shift.required_skill not in shift.employee.skills)
        .penalize(HardSoftScore.ONE_HARD)
        .as_constraint("Required skill")
    )


def unavailable_day(f: ConstraintFactory):
    # Hard: nobody works on a day they are unavailable.
    return (
        f.for_each(Shift)
        .filter(lambda shift: shift.day in shift.employee.unavailable_days)
        .penalize(HardSoftScore.ONE_HARD)
        .as_constraint("Unavailable day")
    )
  • f.for_each(Shift) streams every shift whose employee is assigned, so a lambda can read shift.employee.skills without checking for None. Use f.for_each_including_unassigned(Shift) for a rule about unassigned shifts.
  • penalize(HardSoftScore.ONE_HARD) costs one hard point per match.
  • as_constraint(name) names the rule. The name appears in the explanation, and two rules may not share one.

A rule about pairs: join on a key

"One shift per employee per day" is about two shifts at once. for_each_unique_pair pairs shifts that agree on every joiner, each unordered pair once:

def one_shift_per_day(f: ConstraintFactory):
    # Hard: at most one shift per employee per day.
    return (
        f.for_each_unique_pair(
            Shift,
            Joiners.equal(lambda shift: shift.employee),
            Joiners.equal(lambda shift: shift.day),
        )
        .penalize(HardSoftScore.ONE_HARD)
        .as_constraint("One shift per day")
    )

Pairs are found by looking the joiners' values up in an index, never by comparing every shift with every other, so this stays fast on large problems. Put every condition that can be an equal into a joiner, not a filter.

A rule about groups: group, collect, penalise

The soft rule spreads work evenly. Group the shifts by employee, count each group, and penalise the square of the count — squaring makes 4 + 3 cheaper than 5 + 2, so the search prefers even loads:

def fair_workload(f: ConstraintFactory):
    # Soft: spread the work. The square of each employee's shift count makes
    # 4 + 3 cheaper than 5 + 2.
    return (
        f.for_each(Shift)
        .group_by(lambda shift: shift.employee, ConstraintCollectors.count())
        .penalize(HardSoftScore.ONE_SOFT, lambda employee, count: count * count)
        .as_constraint("Fair workload")
    )

The second argument of penalize is a weight: a lambda over the match, here the group's (key, value). The collectors are count, sum, min, max and load_balance, each maintained incrementally: moving one shift updates two groups, not all of them.

6. The data

A week where each day needs one doctor and one nurse. Ann cannot work on day 2, Bob on day 5, and Cai is both a doctor and a nurse.

ann = Employee("Ann", {"doctor"}, {2})
bob = Employee("Bob", {"nurse"}, {5})
cai = Employee("Cai", {"doctor", "nurse"})
dee = Employee("Dee", {"nurse"})

problem = Schedule(
    employees=[ann, bob, cai, dee],
    shifts=[Shift(day, skill) for day in range(7) for skill in ("doctor", "nurse")],
)

7. Solve

solved = heusor.solve(problem, constraints, step_limit=20_000, seed=42)
print("score:", solved.score)

solve returns a solved copy; problem is unchanged. Heusor chooses the method itself: a quick first complete answer, then a search that keeps improving it (Concepts). heusor.config_for(problem, constraints, …) returns the exact configuration it will run, if you want to see or change it.

Choose the budget deliberately:

  • step_limit=n with a seed is reproducible: same seed, same input, same answer, on any machine. Use it for tests.
  • time_limit=seconds is what production usually wants — the time you actually have — but a faster machine does more steps, so answers differ between machines. Construction counts against it.

8. Read the answer — and why it scores that way

explanation = heusor.score(solved, constraints)
if not explanation.complete:
    raise SystemExit("the budget ran out before every shift was assigned")
for name, score in explanation.constraints.items():
    print(f"{name:>18}: {score}")
for shift in solved.shifts:
    print(shift.day, f"{shift.required_skill:<6}", shift.employee.name)
score: HardSoftScore(hard=0, soft=-50)
    Required skill: HardSoftScore(hard=0, soft=0)
   Unavailable day: HardSoftScore(hard=0, soft=0)
 One shift per day: HardSoftScore(hard=0, soft=0)
     Fair workload: HardSoftScore(hard=0, soft=-50)
0 doctor Ann
...

hard=0 means feasible: every hard rule holds. soft=-50 is the fairness penalty. Fourteen shifts across four people is at best 4, 4, 3 and 3 shifts, so 16 + 16 + 9 + 9 = 50: this answer is the best possible.

Always check complete. Construction draws on the same budget as the search. If the budget runs out first, some shifts are still unassigned, and the score can look healthy while the schedule is empty.

explanation.constraints breaks the score down by rule and adds up to it. explanation.indictments[name] lists the objects each rule's matches involve, and explanation.blame(obj) totals one object's share.

Check the answer independently

The engine's score is only as right as your constraints. The test that runs this page recounts every hard rule from the solved schedule itself, without the engine. For anything that matters, write that check too.

Where next