Skip to content

Invariants

Rules that hold at all times across the library. Breaking any of these is a bug, not a design choice.

Return values

Every node returns a truthy value on success, falsy on failure. SUCCESS = True, FAILURE = False. All composite nodes propagate this contract — never return None or raise uncaught exceptions as the signal.

ControlFlowException.__bool__() is always False. A wrapped exception is always falsy, regardless of what it wraps. Code that tests if result: reliably distinguishes success from failure even when exceptions are involved.

sequence() returns a list on success, a falsy scalar on failure. The success value is list[results], not a bare result. Callers that expect a single value must unwrap it.

fallback() and selector() wrap their success result in a list. [result] on first success, last falsy result otherwise. Same list-wrapping contract as sequence.

parallele() returns a bool, not a result list. True if successes ≥ threshold, False if failures ≥ threshold.

parallel_race() returns the first finisher's result, or FAILURE if the child list is empty.

State

BTreeRunner.run() executes in an isolated context copy. copy_context() is called at __enter__. Each run() call gets a fresh copy. Mutations inside a call never propagate back to the caller's context and never accumulate across ticks.

ControlFlowException.instantiate() is idempotent. Passing an already-wrapped exception returns it unchanged — no double-wrapping.

API contracts

success_threshold must be in [0, len(children)]. Both sequence() and parallele() assert this. Out-of-range values raise AssertionError at call time, not at runtime.

BTreeRunner.run() must be called inside the context manager. Calling it outside raises RuntimeError. Always use async with BTreeRunner(...) as runner:.

get_node_metadata() raises RuntimeError if the target has no __node_metadata. Decorating a function with @action or @condition attaches the metadata. Calling get_node_metadata() on an undecorated callable is an error.

action() wraps all exceptions as ControlFlowException. No exception escapes a node boundary as a raw exception. The only way a node signals failure-via-exception is through ControlFlowException, which is falsy.

Behavioral guarantees

do_while() always executes the child at least once. The condition is checked after the first execution. Use repeat_while() if zero iterations is a valid outcome.

repeat_while() returns FAILURE immediately if the condition is falsy on the first check. Zero iterations produce FAILURE, not SUCCESS.

repeat_until() returns FAILURE immediately if the condition is truthy on the first check. Zero iterations produce FAILURE, not SUCCESS.

decision() with no failure_tree returns SUCCESS when the condition is falsy. It does not return FAILURE. Omitting failure_tree means "do nothing and succeed" on the false branch.

parallel_race() cancels all remaining tasks when the first child finishes. Tasks are not left running in the background.