Concepts¶
Heusor solves planning problems: you have things to assign — shifts to people, deliveries to vans, lessons to rooms — and rules the assignment should follow. There are far too many possible assignments to try them all, so the solver starts from a reasonable one, keeps improving it, and hands you the best it found in the time you allowed.
This page explains the few ideas you need to describe such a problem. The getting-started guide shows them in running code.
The problem: facts, entities, variables¶
Every planning problem has three kinds of data, each an ordinary dataclass with a decorator.
Problem facts (@problem_fact) are input the solver never changes:
employees and their skills, rooms and their capacities, customers and their
locations.
Planning entities (@planning_entity) are the things the solver changes —
one object per decision to be made: a shift, a lesson, a vehicle.
Planning variables are the fields on an entity that hold the decision. There are two kinds:
| kind | declared as | holds | typical problem |
|---|---|---|---|
| basic | Annotated[Employee \| None, PlanningVariable] |
one value, chosen from the solution's list of that type | who works this shift; which room a lesson gets |
| list | Annotated[list[Visit], PlanningListVariable] |
an ordered list of entities | the order a vehicle visits customers |
A basic variable is None until the solver assigns it. Declare it with
PlanningVariable(allows_unassigned=True) when leaving it empty is a
legitimate answer — an over-constrained roster where some shifts go
unstaffed; otherwise every variable ends up assigned.
An entity with a PlanningPin field set to True keeps its current values:
the solver plans around it. That is how a manager's fixed choices, or the part
of a plan already published, stay put.
The planning solution (@planning_solution) holds the lists of facts and
entities, and a score field the solver fills in.
Scores: hard and soft¶
A score has one or more levels, compared in order:
| type | levels | use |
|---|---|---|
SimpleScore |
one | a single objective |
HardSoftScore |
hard, soft | rules that must hold, and preferences |
HardMediumSoftScore |
hard, medium, soft | a middle tier, e.g. "staff every shift" between rules and preferences |
Hard rules must hold for an answer to be usable; soft rules say which usable answer is better. A solution is feasible when its hard score is 0. Any improvement at a higher level beats any amount at a lower one.
Constraints and incremental scoring¶
Rules are written as constraint streams: start from the entities, narrow
to the ones that break a rule, penalise each match — or reward the matches you
want more of. Streams can pair entities (for_each_unique_pair, join),
group them (group_by, with collectors such as count, sum and
count_distinct), and check for existence (if_exists, if_not_exists).
The constraint cookbook has one recipe per common
rule.
Why streams, rather than one function that adds up the score? Speed. The solver tries a very large number of small changes, and each touches only one or two entities. Because the engine knows what each rule looks at, it re-checks only the matches a change affects.
Your lambdas are compiled once, when the problem is built, into the engine's own form. No Python runs while the solver searches. A lambda that uses something the compiler does not translate fails then, naming the file, the line and the construct — never halfway through a solve.
The solve¶
solved = heusor.solve(problem, constraints, time_limit=…, step_limit=…, seed=…)
solve returns a solved copy; your problem object is not changed. It
works in two stages: a construction that assigns every variable quickly,
then a local search that keeps improving the assignment until the budget
runs out. heusor.config_for(problem, constraints, …) shows the exact
configuration a solve would use.
Choose the budget deliberately:
step_limitwith aseedis reproducible: the same input gives the same answer — checked on Linux x86-64, macOS on Apple silicon and Windows x86-64. Use it in tests.time_limit(seconds, construction included) is what production usually wants: the time you actually have. A faster machine does more steps, so answers can differ between machines and runs.
Check that every variable was assigned. Construction draws on the same
budget; if it runs out first, the score can look healthy while the plan is
empty. heusor.score(solved, constraints).complete says whether it finished.
Explaining the answer¶
explanation = heusor.score(solution, constraints)
scores a solution without searching, and explains it:
explanation.score— the total;explanation.constraints— each rule's share, adding up to the total;explanation.indictments[name]— which of your own objects a rule's matches involve, and what they cost;explanation.blame(obj)— one object's share across all rules.
Beyond a single solve¶
- Cancellation. Pass
cancel=heusor.Cancel()and call.cancel()from another thread; the solve stops early and returns the best solution so far. - Progress.
on_progress=fnreceives reports while the solve runs — when it starts a stage, when the best score improves, and periodically while it works.
What the package does not do yet is listed under Current limits in the reference.