Skip to content

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_limit with a seed is 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=fn receives 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.