Skip to content

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 id(allocation)), the viable entities and their preliminary-rule scores. Candidates with compatible=False from any preliminary rule have already been pruned; what's left passed every binary check.

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.add_assignment.

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 by assignments for output adapters.
  • _by_entity — a per-entity index keyed by id(entity). Made the primary lookup path for get_assignments_done_by so 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
assignments: list[Assignment[TAllocationRequest, TEntity]]

All planned allocations recorded in this state, in the order they were added.

remove_assignment
remove_assignment(
    allocation: Assignment[TAllocationRequest, TEntity],
) -> None

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
get_assignments_done_by(
    entity: TEntity,
) -> list[Assignment[TAllocationRequest, TEntity]]

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:

  • BacktrackingAssignmentAlgorithm raises when its search exhausts the candidate space (or the iteration budget) without filling every feasible allocation.
  • OrToolsAssignmentAlgorithm raises when CP-SAT reports INFEASIBLE or MODEL_INVALID.
  • LoadBalancingAssignmentAlgorithm never 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_count are 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_candidates highest-scored candidates per allocation enter the search. Default 30.
  • Iteration cap. A hard max_iterations budget 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 entity j is assigned to allocation i, 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 exactly required_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 (ortools is ~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
check(
    entity: TEntity, allocation: TAllocationRequest
) -> bool

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
evaluate(
    entity: TEntity, allocation: TAllocationRequest
) -> RuleVerdict

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
score(
    entity: TEntity, allocation: TAllocationRequest
) -> float

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 False here 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 score of 1.0 means the rule is fully satisfied without preference; lower scores express soft preference; 0.0 is treated as a drop (the geometric- mean aggregator collapses to zero, pruning the candidate). The framework aggregates scores across rules using the geometric mean — see docs/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
inclusive_day_count: int

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:592025-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
days: int

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.

Unavailability

Bases: DateRange[T]

A date range during which an entity isn't available, plus a short reason.

Extends DateRange with a free-form reason string for diagnostics. Domains that need richer metadata (paid/unpaid, source system, etc.) subclass and add fields.

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

Unavailability

Bases: DateRange[T]

A date range during which an entity isn't available, plus a short reason.

Extends DateRange with a free-form reason string for diagnostics. Domains that need richer metadata (paid/unpaid, source system, etc.) subclass and add fields.

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
inclusive_day_count: int

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:592025-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
days: int

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 False here 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 score of 1.0 means the rule is fully satisfied without preference; lower scores express soft preference; 0.0 is treated as a drop (the geometric- mean aggregator collapses to zero, pruning the candidate). The framework aggregates scores across rules using the geometric mean — see docs/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
evaluate(
    entity: TEntity, allocation: TAllocationRequest
) -> RuleVerdict

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
check(
    entity: TEntity, allocation: TAllocationRequest
) -> bool

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
score(
    entity: TEntity, allocation: TAllocationRequest
) -> float

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 id(allocation)), the viable entities and their preliminary-rule scores. Candidates with compatible=False from any preliminary rule have already been pruned; what's left passed every binary check.

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.add_assignment.

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 by assignments for output adapters.
  • _by_entity — a per-entity index keyed by id(entity). Made the primary lookup path for get_assignments_done_by so 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
assignments: list[Assignment[TAllocationRequest, TEntity]]

All planned allocations recorded in this state, in the order they were added.

remove_assignment
remove_assignment(
    allocation: Assignment[TAllocationRequest, TEntity],
) -> None

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
get_assignments_done_by(
    entity: TEntity,
) -> list[Assignment[TAllocationRequest, TEntity]]

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:

  • BacktrackingAssignmentAlgorithm raises when its search exhausts the candidate space (or the iteration budget) without filling every feasible allocation.
  • OrToolsAssignmentAlgorithm raises when CP-SAT reports INFEASIBLE or MODEL_INVALID.
  • LoadBalancingAssignmentAlgorithm never 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_count are 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_candidates highest-scored candidates per allocation enter the search. Default 30.
  • Iteration cap. A hard max_iterations budget 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 entity j is assigned to allocation i, 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 exactly required_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 (ortools is ~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=False drops 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.