API Reference¶
Auto-generated from the source. Click any class to see its full signature, generic parameters, fields, and methods.
Public surface (beekeeper)¶
beekeeper ¶
Root entry to BeeKeeper's interface.
AllocationInputAdapter ¶
Bases: ABC
Yields the allocation requests the pipeline must fill.
Implementations decide where requests come from — JSON, a DB, an HTTP
call, an in-memory fixture. The framework iterates the result exactly
once per BeeKeeper.execute() call.
CompositeInputAdapter
dataclass
¶
Bases: InputAdapter[TEntity, TAllocationRequest]
Composes a separate entity adapter and allocation adapter into one InputAdapter.
The two halves of the input contract — entities and allocations — often
come from different sources (a workforce DB and a request queue, two
different JSON files, etc.). This adapter wires them together so the
BeeKeeper pipeline can consume a single object.
EntityInputAdapter ¶
Bases: ABC
Yields the pool of entities (workers, vehicles, …) the pipeline can assign.
Implementations decide where entities come from — JSON, a DB, an HTTP
call, an in-memory fixture. The framework iterates the result exactly
once per BeeKeeper.execute() call.
InputAdapter ¶
Bases: EntityInputAdapter[TEntity], AllocationInputAdapter[TAllocationRequest], ABC
The contract BeeKeeper consumes: a single source for both entities and allocations.
Implement this directly when one source naturally yields both (a single
database, a single JSON document), or use CompositeInputAdapter to
wire two separate EntityInputAdapter / AllocationInputAdapter
instances together.
JsonAllocationInputAdapter
dataclass
¶
Bases: AllocationInputAdapter[TAllocationRequest]
Strict JSON-backed adapter that loads allocation requests from a file.
The file must contain a JSON array of objects matching
allocation_type's schema. Validation is strict: any field not
declared on the target allocation (or its nested types) will raise. This
is enforced at the framework level — the framework's
AllocationRequest base class sets
model_config = ConfigDict(extra="forbid"), which subclasses inherit
unless they explicitly opt out.
Want lenient parsing? Implement your own AllocationInputAdapter
subclass — the core only ships strict, well-defined, Pydantic-backed
adapters.
JsonEntityInputAdapter
dataclass
¶
Bases: EntityInputAdapter[TEntity]
Strict JSON-backed adapter that loads entities from a file.
The file must contain a JSON array of objects matching entity_type's
schema. Validation is strict: any field not declared on the target
entity (or any field declared on a nested model and unset on the JSON)
will raise. This is enforced at the framework level — the framework's
Entity base class sets model_config = ConfigDict(extra="forbid"),
which subclasses inherit unless they explicitly opt out.
Want lenient parsing for legacy data, exploratory work, or
third-party feeds? Implement your own EntityInputAdapter subclass
— the core only ships strict, well-defined, Pydantic-backed adapters.
ConsoleOutputAdapter ¶
Bases: OutputAdapter[TEntity, TAllocationRequest]
Prints planned allocations to stdout.
Useful for examples, smoke testing, and manual inspection. Production code should ship a domain-specific output adapter (database write, API call, file export) instead of relying on this one.
OutputAdapter ¶
Bases: ABC
Receives the final AssignmentState after the algorithm chain runs.
Multiple output adapters can be wired into a single pipeline (a console
printer + a database writer + a metrics sink); the framework calls
handle_output on each in order. Adapters report errors by raising,
not by return value — exceptions propagate to the execute() caller.
Algorithm ¶
Bases: ABC
Abstract base for assignment algorithms.
Subclass and override :meth:run to bring a custom strategy. The bundled
implementations — LoadBalancingAssignmentAlgorithm,
BacktrackingAssignmentAlgorithm, OrToolsAssignmentAlgorithm —
are concrete subclasses re-exported from the top-level package.
Algorithms signal "couldn't produce a complete solution" by raising
:class:IncompleteSolutionError; the framework's chain runner catches
that and falls through to the next algorithm in the sequence.
run
abstractmethod
¶
run(
allocations: Iterable[TAllocationRequest],
entities: Iterable[TEntity],
candidates: Mapping[int, list[Candidate[TEntity]]],
rules: Iterable[
StatefulRule[TEntity, TAllocationRequest]
],
) -> AssignmentState[TEntity, TAllocationRequest]
The entry point to your sorting and allocating algorithm.
You receive the full list of allocations to assign, the full list of entities, the per-allocation candidate map (already pruned by preliminary rules and decorated with aggregate scores), and the stateful rules you must consult during assignment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
allocations
|
Iterable[TAllocationRequest]
|
The list of tasks you need to assign. |
required |
entities
|
Iterable[TEntity]
|
The work entities ("employees") you have to complete the allocations. |
required |
candidates
|
Mapping[int, list[Candidate[TEntity]]]
|
For each allocation (keyed by |
required |
rules
|
Iterable[StatefulRule[TEntity, TAllocationRequest]]
|
The stateful rules that must hold given the in-progress AssignmentState — consult them before adding an Assignment. |
required |
Returns:
| Type | Description |
|---|---|
AssignmentState[TEntity, TAllocationRequest]
|
The final state of the algorithm, where the chosen allocations |
AssignmentState[TEntity, TAllocationRequest]
|
have been assigned via |
AssignmentState ¶
The algorithm's working memory — the schedule built up so far.
Hot path: stateful rules and the load-balancing algorithm consult it on every candidate, so lookups must stay cheap.
Two parallel views of the same data are maintained:
_allocations— the canonical insertion-ordered list of planned allocations. Iterated byassignmentsfor output adapters._by_entity— a per-entity index keyed byid(entity). Made the primary lookup path forget_assignments_done_byso queries don't scan the whole schedule. Worth its weight: stateful rules and load-balancing algorithms call this method often, and an O(n) scan per call dominated the wall-clock runtime on the 200-worker fixture before the index existed.
The two views are kept in sync inside add_assignment /
remove_assignment; everything else is read-only.
assignments
property
¶
All planned allocations recorded in this state, in the order they were added.
remove_assignment ¶
Remove allocation from both views by identity, not equality.
Assignment is a frozen dataclass and so compares structurally
equal whenever two distinct instances share the same allocation and
assigned entities. list.remove matches by __eq__ and would pop
the first equal entry — silently desynchronising the flat list from the
per-entity index during backtracking churn where multiple structurally
equal planned allocations coexist. Scanning for the exact instance
keeps the two views in lock-step. The lists are short enough that the
linear scan is fine.
get_assignments_done_by ¶
Allocations the given entity is assigned to. O(k) where k is that entity's count.
IncompleteSolutionError ¶
Bases: Exception
Raised by an algorithm that couldn't produce a result it considers complete.
What "complete" means is the algorithm's call:
BacktrackingAssignmentAlgorithmraises when its search exhausts the candidate space (or the iteration budget) without filling every feasible allocation.OrToolsAssignmentAlgorithmraises when CP-SAT reportsINFEASIBLEorMODEL_INVALID.LoadBalancingAssignmentAlgorithmnever raises — it returns whatever schedule it could fill, which by its semantics is always "complete" for what it's trying to do.
The framework's flow uses this exception to walk through an algorithm
chain (passed to BeeKeeper(algorithm=[...])): if an algorithm raises,
the next one in the chain runs from scratch. Putting load-balancing
last in the chain guarantees the chain always produces a result.
The optional partial_state is whatever the algorithm had built up
when it gave up. It's primarily for inspection/debugging — the chain
runner doesn't pass it forward to the next algorithm. May be None
if the algorithm had nothing meaningful to return.
Parameterized over the same TypeVars as the algorithm that raises it
so callers who catch and inspect partial_state get type-checked
field access rather than Any.
BacktrackingAssignmentAlgorithm ¶
Bases: Algorithm[TEntity, TAllocationRequest]
Depth-first backtracking solver.
For each feasible allocation in input order, tries combinations of candidate entities (top-K by score). When a combination satisfies every stateful rule, the algorithm tentatively assigns it and recurses into the next allocation. If a downstream allocation fails to find any valid combination, the algorithm backtracks and tries the next combination at the previous level.
This finds complete solutions where greedy gets stuck — e.g., when an entity is the only candidate for two overlapping allocations and a stateful rule (like "no double-booking") forbids both. Greedy assigns the first one and fails the second; backtracking explores both orderings.
Three guards keep the worst-case search tractable:
- Feasibility filter. Allocations whose candidate pool is smaller than
their
required_countare unfulfillable by definition and are skipped before the search starts. Letting them through would cause the search to exhaustively try every combination of every earlier allocation before giving up. - Top-K candidate cap. Only the
top_k_candidateshighest-scored candidates per allocation enter the search. Default 30. - Iteration cap. A hard
max_iterationsbudget on recursive calls so even pathological inputs don't run forever.
If the search exhausts without finding a complete assignment for the
feasible set, raises IncompleteSolutionError. Wire this algorithm
in front of LoadBalancingAssignmentAlgorithm (which never raises)
via BeeKeeper(algorithm=[BacktrackingAssignmentAlgorithm(),
LoadBalancingAssignmentAlgorithm()]) to fall back automatically.
LoadBalancingAssignmentAlgorithm ¶
Bases: Algorithm[TEntity, TAllocationRequest]
Greedy with a load-balancing penalty so work disperses across the entity pool.
The vanilla greedy reference picks the highest-scored compatible candidate for each allocation. When several allocations have overlapping candidate pools, that means the best-scoring entity gets assigned to most of them — realistic in toy data, miserable in production where the same worker ends up scheduled for every shift while everyone else sits idle.
This algorithm replaces the raw candidate score with::
adjusted_score = candidate.score / (1 + load)
where load is the count of allocations the entity is already assigned
to. The first time an entity is considered, the division is by 1 (no
penalty); the second time, by 2 (half the score); etc. Other dynamics are
otherwise identical to greedy: candidates are ranked, stateful rules are
consulted, the top required_count are chosen, allocation is skipped
if not enough candidates qualify.
The result is that work spreads out — entities with no assignments yet are preferred over equally-scored entities who already have several. No randomness; the algorithm is fully deterministic for a given input.
Load is read from state.get_assignments_done_by (now O(k) thanks to
the AssignmentState's per-entity index); no side bookkeeping is needed.
OrToolsAssignmentAlgorithm ¶
Bases: Algorithm[TEntity, TAllocationRequest]
Constraint-programming solver via Google OR-Tools' CP-SAT backend.
Formulates the assignment as an integer program:
- Variables.
x[i, j]is a boolean: 1 if entityjis assigned to allocationi, 0 otherwise. Variables are only created for (allocation, entity) pairs that appear in the candidate map — pairs pruned by stage-1 unavailability filtering or stage-2 preliminary rules don't enter the formulation at all. - Constraints. For each allocation, the sum of its
x[i, j]values is either 0 (unfulfilled) or exactlyrequired_count— no partial fills. - Objective. Maximize the total weighted score of assignments,
where each
x[i, j]is weighted by the candidate's score from stage 2. The solver naturally prefers full coverage; ties are broken by score.
Stateful rules are not modeled in the CP-SAT formulation. Encoding
arbitrary Python predicates as CP-SAT constraints is the wrong tool —
rules are imperative code, not declarative constraints. Domains that
need stateful guarantees should compose this algorithm with a
post-processing step (or use BacktrackingAssignmentAlgorithm,
which evaluates stateful rules during the search).
Pros over the heuristic implementations:
- Globally optimal under the modeled constraints.
- Scales well to thousands of variables; CP-SAT is industrial-grade.
Cons:
- Heavy dependency (
ortoolsis ~50 MB). - No stateful-rule support.
- Solver overhead: small problems (the McDonald's fixtures) often finish slower than the heuristic algorithms due to model-build and solver-init costs. Pays off above ~500 entity-allocation pairs.
AllocationRequest ¶
Bases: BaseModel
A request for entities to be assigned over a date range.
The third type parameter TDate controls the granularity of the
contained date_range. It defaults to datetime (the common
case) but a domain that only schedules in whole-day units can
parameterize as AllocationRequest[MyType, MyEntity, date] and
drop the time-of-day component everywhere.
AllocationType ¶
Bases: AbstractEnum
Empty placeholder enum — domains extend it with their own task vocabulary.
Subclass and add members representing the kinds of allocation a request can carry::
class McAllocType(AllocationType):
COOKING = "COOKING"
SERVING_FOOD = "SERVING_FOOD"
Assignment
dataclass
¶
The result of assigning one or more entities to an allocation request.
Composition rather than inheritance: an assignment has a request and
has its assigned entities. Treating it as an AllocationRequest
subclass blurred those concerns and made it harder to add fields like
assignment confidence or audit trail without polluting the request schema.
Plain @dataclass rather than a pydantic BaseModel: assignments
are framework-constructed result objects, not crossings of an IO
boundary. They never need validation (the request and the entities were
already validated when they entered the pipeline) and they never need to
be parsed from JSON. Skipping pydantic also sidesteps the bound +
extra="forbid" interaction that would reject domain-specific fields
on the assigned entities when Assignment is constructed without
explicit parameterization.
If you need to serialize an assignment to JSON, use dataclasses.asdict
or write your own serializer in your output adapter.
AbstractEnum ¶
Bases: Enum
Enum base that honors @abstractmethod on its subclasses.
Subclasses that declare abstract methods must implement them before
adding members; otherwise class creation raises TypeError. Empty
subclasses (no members) are allowed so the codebase's two-step pattern
— define a domain placeholder, then a concrete enum that fills it in —
keeps working.
Entity ¶
Bases: BaseModel
An assignable thing — worker, vehicle, machine — that carries its own time-off list.
Generic over the concrete Unavailability subtype so a domain that
extends the base class (e.g. with an is_paid_leave boolean) keeps
its richer type visible end-to-end through the pipeline.
BeeKeeper ¶
The framework's orchestrator. Wires inputs, rules, an algorithm, and outputs into a runnable pipeline.
Construct with either algorithm= (uses the default 3-stage pipeline)
or stages= (you own the wiring). Call :meth:execute to run it.
Type parameters flow through every layer so BeeKeeper[McWorker,
McRequest] gives you static-checked domain types end-to-end.
Just buzzing along... 🐝 ~ ~ ~ Don't mind me...
AvailabilityRule ¶
Bases: HardPreliminaryRule[TEntity, TAllocationRequest]
Reject an entity whose unavailabilities overlap the allocation's date range at all.
Stricter than the stage-1 candidate filter (which only blocks on full coverage). Use this rule when "any conflict at all" should disqualify — e.g. "if you have any appointment during the shift, you can't take it" semantics. Stage 1 alone would still let an entity through if their unavailability covered only part of the allocation's range.
RequestedEntityRule ¶
Bases: HardPreliminaryRule[TEntity, TAllocationRequest]
If the allocation explicitly requests entities, only those entities are eligible.
Stage 1 already enforces this when the default pipeline is used, but the rule is available for domains that supply custom flow stages and want the same semantic without re-implementing it.
Membership is checked by identity (is), not structural
equality. Pydantic's auto-generated __eq__ walks fields, so two
Entity instances with identical names / unavailabilities /
domain attributes would compare equal — and a request "I want
this worker" would then accept "any worker that happens to look
like this one". The contract is "this specific object", so identity
is the right relation.
HardPreliminaryRule ¶
Bases: PreliminaryRule[TEntity, TAllocationRequest], ABC
A preliminary rule that is purely binary: either the entity passes or it doesn't.
check
abstractmethod
¶
Return True if this entity may be considered for this allocation.
PreliminaryRule ¶
Bases: ABC
A rule that can be evaluated without referring to the in-progress schedule.
Preliminary rules answer "is this entity, on principle, capable of fulfilling this allocation?" — checks like rank eligibility, exemptions, or static availability. They run before the algorithm assigns anything, and their verdicts are aggregated into a per-(allocation, entity) candidate score the algorithm consumes.
Most rules want one of the convenience subclasses — HardPreliminaryRule
for binary checks, SoftPreliminaryRule for scoring preferences.
Subclass PreliminaryRule directly only when you need both axes
(compatibility AND a non-trivial score) in a single rule.
evaluate
abstractmethod
¶
Return the rule's verdict for this (entity, allocation) pair.
SoftPreliminaryRule ¶
Bases: PreliminaryRule[TEntity, TAllocationRequest], ABC
A preliminary rule that expresses preference rather than hard eligibility.
Soft rules never veto an entity (compatible is always True); they
only contribute to the entity's aggregate score so the algorithm can
prefer one viable candidate over another.
score
abstractmethod
¶
Return a non-negative preference score; higher is better.
RuleVerdict
dataclass
¶
The outcome of evaluating a single rule against an (entity, allocation) pair.
A rule's evaluation answers two related questions:
- compatible: does the entity satisfy this rule's hard requirements? A
Falsehere drops the candidate from consideration entirely. - score: how well does the entity satisfy this rule, on an arbitrary
non-negative finite scale? Higher is better. A
scoreof1.0means the rule is fully satisfied without preference; lower scores express soft preference;0.0is treated as a drop (the geometric- mean aggregator collapses to zero, pruning the candidate). The framework aggregates scores across rules using the geometric mean — seedocs/explanations/soft-rules-aggregation.md.
NaN, inf, -inf, and negative scores are rejected at
construction. NaN in particular would poison the aggregator (it flows
through math.log), and inf / negative values produce nonsense
orderings; failing loudly at the rule boundary keeps the pipeline's
downstream invariants intact.
Use the convenience subclasses HardPreliminaryRule / HardStatefulRule
to author binary rules, SoftPreliminaryRule / SoftStatefulRule to
author scoring rules; both wrap their result in a RuleVerdict
automatically.
HardStatefulRule ¶
Bases: StatefulRule[TEntity, TAllocationRequest], ABC
A stateful rule that is purely binary: either the entity passes or it doesn't.
check
abstractmethod
¶
check(
entity: TEntity,
allocation: TAllocationRequest,
state: AssignmentState[TEntity, TAllocationRequest],
) -> bool
Return True if this entity may take this allocation given the current state.
SoftStatefulRule ¶
Bases: StatefulRule[TEntity, TAllocationRequest], ABC
A stateful rule that expresses preference rather than hard eligibility.
Soft rules never veto an entity (compatible is always True); they
only contribute to the entity's aggregate score given the current state.
score
abstractmethod
¶
score(
entity: TEntity,
allocation: TAllocationRequest,
state: AssignmentState[TEntity, TAllocationRequest],
) -> float
Return a non-negative preference score given the current state; higher is better.
StatefulRule ¶
Bases: ABC
A rule whose verdict depends on the in-progress assignment state.
Stateful rules answer "given what's already been planned, can this entity
take this allocation?" — checks like consecutive-shift limits, weekly
hour caps, or rotation requirements. The algorithm consults them as it
assigns, so they have access to the current AssignmentState.
Most rules want one of the convenience subclasses — HardStatefulRule
for binary checks, SoftStatefulRule for scoring preferences.
evaluate
abstractmethod
¶
evaluate(
entity: TEntity,
allocation: TAllocationRequest,
state: AssignmentState[TEntity, TAllocationRequest],
) -> RuleVerdict
Return the rule's verdict for this (entity, allocation, state) triple.
DateRange ¶
Bases: BaseModel
A date or datetime range, inclusive on both ends.
Generic over T: date, which admits both date (whole-day shifts)
and datetime (time-of-day granularity) since datetime is a date
subclass. Domain code that wants to be explicit can subclass with a
concrete parameter, e.g. class Shift(DateRange[date]): ....
inclusive_day_count
property
¶
Number of days the entity is on duty, inclusive on both ends.
A same-day range yields 1 (the entity works that day). A range from Jan 5 to Jan 10 yields 6 (Jan 5, 6, 7, 8, 9, 10).
Computed on the calendar-day component so that datetime ranges
which straddle midnight count every calendar day they touch.
For example, 2025-01-05 23:59 → 2025-01-07 00:01 yields
3 (Jan 5, 6, 7), not 2 (which is what timedelta.days would
truncate to after subtracting the sub-day remainder).
days
property
¶
Elapsed days between start and end, matching stdlib semantics.
A same-day range yields 0; consecutive days yield 1; Jan 5 to
Jan 10 yields 5. For the count of on-duty days, use
:attr:inclusive_day_count.
Adapters¶
Input¶
entity_input_adapter ¶
EntityInputAdapter ¶
Bases: ABC
Yields the pool of entities (workers, vehicles, …) the pipeline can assign.
Implementations decide where entities come from — JSON, a DB, an HTTP
call, an in-memory fixture. The framework iterates the result exactly
once per BeeKeeper.execute() call.
allocation_input_adapter ¶
AllocationInputAdapter ¶
Bases: ABC
Yields the allocation requests the pipeline must fill.
Implementations decide where requests come from — JSON, a DB, an HTTP
call, an in-memory fixture. The framework iterates the result exactly
once per BeeKeeper.execute() call.
input_adapter ¶
InputAdapter ¶
Bases: EntityInputAdapter[TEntity], AllocationInputAdapter[TAllocationRequest], ABC
The contract BeeKeeper consumes: a single source for both entities and allocations.
Implement this directly when one source naturally yields both (a single
database, a single JSON document), or use CompositeInputAdapter to
wire two separate EntityInputAdapter / AllocationInputAdapter
instances together.
composite_input_adapter ¶
CompositeInputAdapter
dataclass
¶
Bases: InputAdapter[TEntity, TAllocationRequest]
Composes a separate entity adapter and allocation adapter into one InputAdapter.
The two halves of the input contract — entities and allocations — often
come from different sources (a workforce DB and a request queue, two
different JSON files, etc.). This adapter wires them together so the
BeeKeeper pipeline can consume a single object.
json_entity_input_adapter ¶
JsonEntityInputAdapter
dataclass
¶
Bases: EntityInputAdapter[TEntity]
Strict JSON-backed adapter that loads entities from a file.
The file must contain a JSON array of objects matching entity_type's
schema. Validation is strict: any field not declared on the target
entity (or any field declared on a nested model and unset on the JSON)
will raise. This is enforced at the framework level — the framework's
Entity base class sets model_config = ConfigDict(extra="forbid"),
which subclasses inherit unless they explicitly opt out.
Want lenient parsing for legacy data, exploratory work, or
third-party feeds? Implement your own EntityInputAdapter subclass
— the core only ships strict, well-defined, Pydantic-backed adapters.
json_allocation_input_adapter ¶
JsonAllocationInputAdapter
dataclass
¶
Bases: AllocationInputAdapter[TAllocationRequest]
Strict JSON-backed adapter that loads allocation requests from a file.
The file must contain a JSON array of objects matching
allocation_type's schema. Validation is strict: any field not
declared on the target allocation (or its nested types) will raise. This
is enforced at the framework level — the framework's
AllocationRequest base class sets
model_config = ConfigDict(extra="forbid"), which subclasses inherit
unless they explicitly opt out.
Want lenient parsing? Implement your own AllocationInputAdapter
subclass — the core only ships strict, well-defined, Pydantic-backed
adapters.
Output¶
output_adapter ¶
OutputAdapter ¶
Bases: ABC
Receives the final AssignmentState after the algorithm chain runs.
Multiple output adapters can be wired into a single pipeline (a console
printer + a database writer + a metrics sink); the framework calls
handle_output on each in order. Adapters report errors by raising,
not by return value — exceptions propagate to the execute() caller.
console ¶
ConsoleOutputAdapter ¶
Bases: OutputAdapter[TEntity, TAllocationRequest]
Prints planned allocations to stdout.
Useful for examples, smoke testing, and manual inspection. Production code should ship a domain-specific output adapter (database write, API call, file export) instead of relying on this one.
Entities and allocations¶
entity ¶
Entity ¶
Bases: BaseModel
An assignable thing — worker, vehicle, machine — that carries its own time-off list.
Generic over the concrete Unavailability subtype so a domain that
extends the base class (e.g. with an is_paid_leave boolean) keeps
its richer type visible end-to-end through the pipeline.
unavailability ¶
date_range ¶
DateRange ¶
Bases: BaseModel
A date or datetime range, inclusive on both ends.
Generic over T: date, which admits both date (whole-day shifts)
and datetime (time-of-day granularity) since datetime is a date
subclass. Domain code that wants to be explicit can subclass with a
concrete parameter, e.g. class Shift(DateRange[date]): ....
inclusive_day_count
property
¶
Number of days the entity is on duty, inclusive on both ends.
A same-day range yields 1 (the entity works that day). A range from Jan 5 to Jan 10 yields 6 (Jan 5, 6, 7, 8, 9, 10).
Computed on the calendar-day component so that datetime ranges
which straddle midnight count every calendar day they touch.
For example, 2025-01-05 23:59 → 2025-01-07 00:01 yields
3 (Jan 5, 6, 7), not 2 (which is what timedelta.days would
truncate to after subtracting the sub-day remainder).
days
property
¶
Elapsed days between start and end, matching stdlib semantics.
A same-day range yields 0; consecutive days yield 1; Jan 5 to
Jan 10 yields 5. For the count of on-duty days, use
:attr:inclusive_day_count.
allocation_type ¶
AllocationType ¶
Bases: AbstractEnum
Empty placeholder enum — domains extend it with their own task vocabulary.
Subclass and add members representing the kinds of allocation a request can carry::
class McAllocType(AllocationType):
COOKING = "COOKING"
SERVING_FOOD = "SERVING_FOOD"
allocation_request ¶
AllocationRequest ¶
Bases: BaseModel
A request for entities to be assigned over a date range.
The third type parameter TDate controls the granularity of the
contained date_range. It defaults to datetime (the common
case) but a domain that only schedules in whole-day units can
parameterize as AllocationRequest[MyType, MyEntity, date] and
drop the time-of-day component everywhere.
assignment ¶
Assignment
dataclass
¶
The result of assigning one or more entities to an allocation request.
Composition rather than inheritance: an assignment has a request and
has its assigned entities. Treating it as an AllocationRequest
subclass blurred those concerns and made it harder to add fields like
assignment confidence or audit trail without polluting the request schema.
Plain @dataclass rather than a pydantic BaseModel: assignments
are framework-constructed result objects, not crossings of an IO
boundary. They never need validation (the request and the entities were
already validated when they entered the pipeline) and they never need to
be parsed from JSON. Skipping pydantic also sidesteps the bound +
extra="forbid" interaction that would reject domain-specific fields
on the assigned entities when Assignment is constructed without
explicit parameterization.
If you need to serialize an assignment to JSON, use dataclasses.asdict
or write your own serializer in your output adapter.
Rules¶
rule_verdict ¶
RuleVerdict
dataclass
¶
The outcome of evaluating a single rule against an (entity, allocation) pair.
A rule's evaluation answers two related questions:
- compatible: does the entity satisfy this rule's hard requirements? A
Falsehere drops the candidate from consideration entirely. - score: how well does the entity satisfy this rule, on an arbitrary
non-negative finite scale? Higher is better. A
scoreof1.0means the rule is fully satisfied without preference; lower scores express soft preference;0.0is treated as a drop (the geometric- mean aggregator collapses to zero, pruning the candidate). The framework aggregates scores across rules using the geometric mean — seedocs/explanations/soft-rules-aggregation.md.
NaN, inf, -inf, and negative scores are rejected at
construction. NaN in particular would poison the aggregator (it flows
through math.log), and inf / negative values produce nonsense
orderings; failing loudly at the rule boundary keeps the pipeline's
downstream invariants intact.
Use the convenience subclasses HardPreliminaryRule / HardStatefulRule
to author binary rules, SoftPreliminaryRule / SoftStatefulRule to
author scoring rules; both wrap their result in a RuleVerdict
automatically.
preliminary_rule ¶
PreliminaryRule ¶
Bases: ABC
A rule that can be evaluated without referring to the in-progress schedule.
Preliminary rules answer "is this entity, on principle, capable of fulfilling this allocation?" — checks like rank eligibility, exemptions, or static availability. They run before the algorithm assigns anything, and their verdicts are aggregated into a per-(allocation, entity) candidate score the algorithm consumes.
Most rules want one of the convenience subclasses — HardPreliminaryRule
for binary checks, SoftPreliminaryRule for scoring preferences.
Subclass PreliminaryRule directly only when you need both axes
(compatibility AND a non-trivial score) in a single rule.
evaluate
abstractmethod
¶
Return the rule's verdict for this (entity, allocation) pair.
HardPreliminaryRule ¶
Bases: PreliminaryRule[TEntity, TAllocationRequest], ABC
A preliminary rule that is purely binary: either the entity passes or it doesn't.
check
abstractmethod
¶
Return True if this entity may be considered for this allocation.
SoftPreliminaryRule ¶
Bases: PreliminaryRule[TEntity, TAllocationRequest], ABC
A preliminary rule that expresses preference rather than hard eligibility.
Soft rules never veto an entity (compatible is always True); they
only contribute to the entity's aggregate score so the algorithm can
prefer one viable candidate over another.
score
abstractmethod
¶
Return a non-negative preference score; higher is better.
stateful_rule ¶
StatefulRule ¶
Bases: ABC
A rule whose verdict depends on the in-progress assignment state.
Stateful rules answer "given what's already been planned, can this entity
take this allocation?" — checks like consecutive-shift limits, weekly
hour caps, or rotation requirements. The algorithm consults them as it
assigns, so they have access to the current AssignmentState.
Most rules want one of the convenience subclasses — HardStatefulRule
for binary checks, SoftStatefulRule for scoring preferences.
evaluate
abstractmethod
¶
evaluate(
entity: TEntity,
allocation: TAllocationRequest,
state: AssignmentState[TEntity, TAllocationRequest],
) -> RuleVerdict
Return the rule's verdict for this (entity, allocation, state) triple.
HardStatefulRule ¶
Bases: StatefulRule[TEntity, TAllocationRequest], ABC
A stateful rule that is purely binary: either the entity passes or it doesn't.
check
abstractmethod
¶
check(
entity: TEntity,
allocation: TAllocationRequest,
state: AssignmentState[TEntity, TAllocationRequest],
) -> bool
Return True if this entity may take this allocation given the current state.
SoftStatefulRule ¶
Bases: StatefulRule[TEntity, TAllocationRequest], ABC
A stateful rule that expresses preference rather than hard eligibility.
Soft rules never veto an entity (compatible is always True); they
only contribute to the entity's aggregate score given the current state.
score
abstractmethod
¶
score(
entity: TEntity,
allocation: TAllocationRequest,
state: AssignmentState[TEntity, TAllocationRequest],
) -> float
Return a non-negative preference score given the current state; higher is better.
builtins ¶
Domain-agnostic rules every scheduler probably wants.
These rules are imported via from beekeeper.rules.builtins import ....
They're deliberately not re-exported from beekeeper itself — they're
opinions about what most schedulers want, not framework primitives, and
the user is expected to choose them explicitly.
AvailabilityRule ¶
Bases: HardPreliminaryRule[TEntity, TAllocationRequest]
Reject an entity whose unavailabilities overlap the allocation's date range at all.
Stricter than the stage-1 candidate filter (which only blocks on full coverage). Use this rule when "any conflict at all" should disqualify — e.g. "if you have any appointment during the shift, you can't take it" semantics. Stage 1 alone would still let an entity through if their unavailability covered only part of the allocation's range.
RequestedEntityRule ¶
Bases: HardPreliminaryRule[TEntity, TAllocationRequest]
If the allocation explicitly requests entities, only those entities are eligible.
Stage 1 already enforces this when the default pipeline is used, but the rule is available for domains that supply custom flow stages and want the same semantic without re-implementing it.
Membership is checked by identity (is), not structural
equality. Pydantic's auto-generated __eq__ walks fields, so two
Entity instances with identical names / unavailabilities /
domain attributes would compare equal — and a request "I want
this worker" would then accept "any worker that happens to look
like this one". The contract is "this specific object", so identity
is the right relation.
Algorithm¶
algorithm ¶
Algorithm ¶
Bases: ABC
Abstract base for assignment algorithms.
Subclass and override :meth:run to bring a custom strategy. The bundled
implementations — LoadBalancingAssignmentAlgorithm,
BacktrackingAssignmentAlgorithm, OrToolsAssignmentAlgorithm —
are concrete subclasses re-exported from the top-level package.
Algorithms signal "couldn't produce a complete solution" by raising
:class:IncompleteSolutionError; the framework's chain runner catches
that and falls through to the next algorithm in the sequence.
run
abstractmethod
¶
run(
allocations: Iterable[TAllocationRequest],
entities: Iterable[TEntity],
candidates: Mapping[int, list[Candidate[TEntity]]],
rules: Iterable[
StatefulRule[TEntity, TAllocationRequest]
],
) -> AssignmentState[TEntity, TAllocationRequest]
The entry point to your sorting and allocating algorithm.
You receive the full list of allocations to assign, the full list of entities, the per-allocation candidate map (already pruned by preliminary rules and decorated with aggregate scores), and the stateful rules you must consult during assignment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
allocations
|
Iterable[TAllocationRequest]
|
The list of tasks you need to assign. |
required |
entities
|
Iterable[TEntity]
|
The work entities ("employees") you have to complete the allocations. |
required |
candidates
|
Mapping[int, list[Candidate[TEntity]]]
|
For each allocation (keyed by |
required |
rules
|
Iterable[StatefulRule[TEntity, TAllocationRequest]]
|
The stateful rules that must hold given the in-progress AssignmentState — consult them before adding an Assignment. |
required |
Returns:
| Type | Description |
|---|---|
AssignmentState[TEntity, TAllocationRequest]
|
The final state of the algorithm, where the chosen allocations |
AssignmentState[TEntity, TAllocationRequest]
|
have been assigned via |
algorithm_state ¶
AssignmentState ¶
The algorithm's working memory — the schedule built up so far.
Hot path: stateful rules and the load-balancing algorithm consult it on every candidate, so lookups must stay cheap.
Two parallel views of the same data are maintained:
_allocations— the canonical insertion-ordered list of planned allocations. Iterated byassignmentsfor output adapters._by_entity— a per-entity index keyed byid(entity). Made the primary lookup path forget_assignments_done_byso queries don't scan the whole schedule. Worth its weight: stateful rules and load-balancing algorithms call this method often, and an O(n) scan per call dominated the wall-clock runtime on the 200-worker fixture before the index existed.
The two views are kept in sync inside add_assignment /
remove_assignment; everything else is read-only.
assignments
property
¶
All planned allocations recorded in this state, in the order they were added.
remove_assignment ¶
Remove allocation from both views by identity, not equality.
Assignment is a frozen dataclass and so compares structurally
equal whenever two distinct instances share the same allocation and
assigned entities. list.remove matches by __eq__ and would pop
the first equal entry — silently desynchronising the flat list from the
per-entity index during backtracking churn where multiple structurally
equal planned allocations coexist. Scanning for the exact instance
keeps the two views in lock-step. The lists are short enough that the
linear scan is fine.
get_assignments_done_by ¶
Allocations the given entity is assigned to. O(k) where k is that entity's count.
errors ¶
IncompleteSolutionError ¶
Bases: Exception
Raised by an algorithm that couldn't produce a result it considers complete.
What "complete" means is the algorithm's call:
BacktrackingAssignmentAlgorithmraises when its search exhausts the candidate space (or the iteration budget) without filling every feasible allocation.OrToolsAssignmentAlgorithmraises when CP-SAT reportsINFEASIBLEorMODEL_INVALID.LoadBalancingAssignmentAlgorithmnever raises — it returns whatever schedule it could fill, which by its semantics is always "complete" for what it's trying to do.
The framework's flow uses this exception to walk through an algorithm
chain (passed to BeeKeeper(algorithm=[...])): if an algorithm raises,
the next one in the chain runs from scratch. Putting load-balancing
last in the chain guarantees the chain always produces a result.
The optional partial_state is whatever the algorithm had built up
when it gave up. It's primarily for inspection/debugging — the chain
runner doesn't pass it forward to the next algorithm. May be None
if the algorithm had nothing meaningful to return.
Parameterized over the same TypeVars as the algorithm that raises it
so callers who catch and inspect partial_state get type-checked
field access rather than Any.
backtracking ¶
BacktrackingAssignmentAlgorithm ¶
Bases: Algorithm[TEntity, TAllocationRequest]
Depth-first backtracking solver.
For each feasible allocation in input order, tries combinations of candidate entities (top-K by score). When a combination satisfies every stateful rule, the algorithm tentatively assigns it and recurses into the next allocation. If a downstream allocation fails to find any valid combination, the algorithm backtracks and tries the next combination at the previous level.
This finds complete solutions where greedy gets stuck — e.g., when an entity is the only candidate for two overlapping allocations and a stateful rule (like "no double-booking") forbids both. Greedy assigns the first one and fails the second; backtracking explores both orderings.
Three guards keep the worst-case search tractable:
- Feasibility filter. Allocations whose candidate pool is smaller than
their
required_countare unfulfillable by definition and are skipped before the search starts. Letting them through would cause the search to exhaustively try every combination of every earlier allocation before giving up. - Top-K candidate cap. Only the
top_k_candidateshighest-scored candidates per allocation enter the search. Default 30. - Iteration cap. A hard
max_iterationsbudget on recursive calls so even pathological inputs don't run forever.
If the search exhausts without finding a complete assignment for the
feasible set, raises IncompleteSolutionError. Wire this algorithm
in front of LoadBalancingAssignmentAlgorithm (which never raises)
via BeeKeeper(algorithm=[BacktrackingAssignmentAlgorithm(),
LoadBalancingAssignmentAlgorithm()]) to fall back automatically.
load_balancing ¶
LoadBalancingAssignmentAlgorithm ¶
Bases: Algorithm[TEntity, TAllocationRequest]
Greedy with a load-balancing penalty so work disperses across the entity pool.
The vanilla greedy reference picks the highest-scored compatible candidate for each allocation. When several allocations have overlapping candidate pools, that means the best-scoring entity gets assigned to most of them — realistic in toy data, miserable in production where the same worker ends up scheduled for every shift while everyone else sits idle.
This algorithm replaces the raw candidate score with::
adjusted_score = candidate.score / (1 + load)
where load is the count of allocations the entity is already assigned
to. The first time an entity is considered, the division is by 1 (no
penalty); the second time, by 2 (half the score); etc. Other dynamics are
otherwise identical to greedy: candidates are ranked, stateful rules are
consulted, the top required_count are chosen, allocation is skipped
if not enough candidates qualify.
The result is that work spreads out — entities with no assignments yet are preferred over equally-scored entities who already have several. No randomness; the algorithm is fully deterministic for a given input.
Load is read from state.get_assignments_done_by (now O(k) thanks to
the AssignmentState's per-entity index); no side bookkeeping is needed.
or_tools ¶
OR-Tools CP-SAT-backed assignment algorithm.
Optional. Requires the ortools extra::
pip install 'beekeeper[ortools]'
uv add 'beekeeper[ortools]'
Importing this module without OR-Tools installed succeeds; instantiating
the algorithm class raises ImportError with the install hint. This
keeps beekeeper.algorithm.implementations importable in environments
that don't need OR-Tools while still providing a clear error when the
algorithm is actually used.
OrToolsAssignmentAlgorithm ¶
Bases: Algorithm[TEntity, TAllocationRequest]
Constraint-programming solver via Google OR-Tools' CP-SAT backend.
Formulates the assignment as an integer program:
- Variables.
x[i, j]is a boolean: 1 if entityjis assigned to allocationi, 0 otherwise. Variables are only created for (allocation, entity) pairs that appear in the candidate map — pairs pruned by stage-1 unavailability filtering or stage-2 preliminary rules don't enter the formulation at all. - Constraints. For each allocation, the sum of its
x[i, j]values is either 0 (unfulfilled) or exactlyrequired_count— no partial fills. - Objective. Maximize the total weighted score of assignments,
where each
x[i, j]is weighted by the candidate's score from stage 2. The solver naturally prefers full coverage; ties are broken by score.
Stateful rules are not modeled in the CP-SAT formulation. Encoding
arbitrary Python predicates as CP-SAT constraints is the wrong tool —
rules are imperative code, not declarative constraints. Domains that
need stateful guarantees should compose this algorithm with a
post-processing step (or use BacktrackingAssignmentAlgorithm,
which evaluates stateful rules during the search).
Pros over the heuristic implementations:
- Globally optimal under the modeled constraints.
- Scales well to thousands of variables; CP-SAT is industrial-grade.
Cons:
- Heavy dependency (
ortoolsis ~50 MB). - No stateful-rule support.
- Solver overhead: small problems (the McDonald's fixtures) often finish slower than the heuristic algorithms due to model-build and solver-init costs. Pays off above ~500 entity-allocation pairs.
Flow¶
beekeeper ¶
BeeKeeper ¶
The framework's orchestrator. Wires inputs, rules, an algorithm, and outputs into a runnable pipeline.
Construct with either algorithm= (uses the default 3-stage pipeline)
or stages= (you own the wiring). Call :meth:execute to run it.
Type parameters flow through every layer so BeeKeeper[McWorker,
McRequest] gives you static-checked domain types end-to-end.
Just buzzing along... 🐝 ~ ~ ~ Don't mind me...
beekeeper_flow_state ¶
BeeKeeperFlowState
dataclass
¶
In-flight state passed between flow stages.
The pipeline materializes the input adapter's iterables into lists at
construction time so every stage can iterate them freely. Stage 1 fills
candidate_map with the entities that could plausibly take each
allocation; stage 2 prunes that map and decorates each remaining
candidate with a score; the algorithm in stage 3 consumes the pruned,
scored map alongside the raw allocations and entities and exposes its
result via algorithm_result for any custom downstream stage.
candidate_map is keyed by id(allocation) because allocation
requests aren't required to be hashable in general — using object
identity sidesteps the requirement and stays stable for the lifetime
of one BeeKeeper.execute() call.
algorithm_result is populated by RunAlgorithmAndDispatchResults
before it dispatches to output adapters, so user-supplied stages
chained after it can inspect the planned allocations. It defaults to
None for callers who replace the algorithm stage entirely.
candidate ¶
Candidate
dataclass
¶
An entity being considered for an allocation, paired with its current score.
Score starts at 1.0 (neutral) and is multiplied by each soft rule's score during the preliminary pass. Hard-rule failures cause the candidate to be pruned from the map entirely rather than recorded with a score of 0.
base_beekeeper_flow_stage ¶
BaseBeeKeeperFlowStage ¶
Bases: ABC
Abstract base for a single step in BeeKeeper.execute()'s pipeline.
Subclass and override :meth:run_stage to add a custom step. Stages
are composed into a list and each is handed the shared
BeeKeeperFlowState; the stage mutates and returns it (or returns a
replacement of the same type) so the next stage sees the cumulative
pipeline so far.
The bundled stages — AssignPossibleEntitiesToAllocations,
RunPreliminaryRules, RunAlgorithmAndDispatchResults — are
concrete subclasses. Replace the default 3-stage list by passing
stages= to BeeKeeper.
run_stage
abstractmethod
¶
run_stage(
state: BeeKeeperFlowState[TEntity, TAllocationRequest],
) -> BeeKeeperFlowState[TEntity, TAllocationRequest]
Run this stage against state and return the (possibly updated) state.
assign_possible_entities_to_allocations ¶
AssignPossibleEntitiesToAllocations ¶
Bases: BaseBeeKeeperFlowStage[TEntity, TAllocationRequest]
Build the per-allocation candidate set the rule pipeline and algorithm consume.
For each allocation, walks the entity list and includes the entity as a
candidate unless it's definitively unavailable: either because the
allocation specifies requested_entities and this entity isn't one of
them, or because the entity has an unavailability that fully covers the
allocation's date range. Partial overlaps pass through — preliminary
rules and the algorithm get the final say on partial-day semantics.
Membership in requested_entities is checked by identity (is),
matching RequestedEntityRule in beekeeper.rules.builtins. Pydantic's
auto-generated __eq__ walks fields, so two Entity instances with
identical attribute values compare equal — and a structural in check
would admit a look-alike entity the caller never named. The contract is
"these specific objects", so identity is the right relation.
All candidates start with a neutral score (1.0); the preliminary-rule
stage multiplies in soft-rule scores and prunes hard-rule failures.
run_preliminary_rules ¶
RunPreliminaryRules ¶
Bases: BaseBeeKeeperFlowStage[TEntity, TAllocationRequest]
Apply preliminary rules to the candidate map: prune incompatibilities, aggregate scores.
For every (allocation, candidate) pair populated by stage 1, every preliminary rule is evaluated. The combined verdict:
- Compatibility is logical AND. Any rule reporting
compatible=Falsedrops the candidate from the allocation's list — there's no point scoring an entity that's hard-disqualified. - Score is the geometric mean of the per-rule verdict scores. The geometric mean treats each rule as an independent multiplicative factor and stays bounded in the same range as its inputs, so a single lukewarm soft rule doesn't tank the whole candidate the way a pure product would (and vice versa, a single high score doesn't drown out the others the way an arithmetic mean might).
Preliminary rules can be run before the algorithm because their verdicts don't depend on what's already been planned. Stateful rules — which do depend on the in-progress schedule — are consulted by the algorithm itself in stage 3.
run_algorithm_and_dispatch_results ¶
RunAlgorithmAndDispatchResults ¶
Bases: BaseBeeKeeperFlowStage[TEntity, TAllocationRequest]
Run the algorithm chain and dispatch the result to every output adapter.
Accepts a sequence of algorithms — typically just one, but providing
several lets a primary algorithm fall back to a simpler one if it can't
produce a complete solution. Each algorithm is tried in order; if it
raises IncompleteSolutionError, the next algorithm in the sequence
runs from scratch. The first non-raising algorithm wins. If every
algorithm raises, the last error propagates to the caller.
Putting an always-completing algorithm last in the chain
(LoadBalancingAssignmentAlgorithm is the one bundled built-in that
never raises) guarantees the chain produces a result.
Pass an on_incomplete_solution callback to observe fall-through
events: it receives the algorithm that raised and the exception,
fires once per failed algorithm in the chain, and exists so production
pipelines can log/alert when a primary algorithm silently degrades to
its fallback. The callback's return value is ignored; raising from
inside it propagates and aborts the chain.
Data structures¶
abstract_enum ¶
AbstractEnum ¶
Bases: Enum
Enum base that honors @abstractmethod on its subclasses.
Subclasses that declare abstract methods must implement them before
adding members; otherwise class creation raises TypeError. Empty
subclasses (no members) are allowed so the codebase's two-step pattern
— define a domain placeholder, then a concrete enum that fills it in —
keeps working.