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 isNoneuntil 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 readshift.employee.skillswithout checking forNone. Usef.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=nwith aseedis reproducible: same seed, same input, same answer, on any machine. Use it for tests.time_limit=secondsis 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¶
- Constraint cookbook: one recipe per common rule.
- Concepts: the ideas behind the model, the score and the solve.
- Package reference: every stream step, what a lambda may use, solve options, errors.
- Worked examples: school timetabling, employee scheduling, vehicle routing.