Branching

Branching allows users to define complex workflows. It also enables parallel executions of non-dependent layers, which can enable significant performance improvements.

Dependencies

Basic branching can achieved by adding layers as parameters to Layer.__call__.

from laminar import Flow, Layer

class BranchFlow(Flow): ...

@BranchFlow.register
class A(Layer):
    def __call__(self) -> None: ...

@BranchFlow.register
class B(Layer):
    def __call__(self, a: A) -> None: ...

@BranchFlow.register
class C(Layer):
    def __call__(self, a: A) -> None: ...
stateDiagram-v2 state BranchFlow { direction LR A --> B B --> C }

When defined in this way, layer A will run first and layers B and C after in parallel because there is no defined dependencies between them.

Conditions

Conditional branching is a common flow control operation, such as if ... else ..., that directs a Flow along a subset of paths. As the Flow is traversed, conditions are evaluated and paths are chosen.

Conditions are defined on layers as an entry hook that return a value to indicate whether the Layer should be executed or not. Because entry hooks are class methods, users can define multiple hooks and include complex logic to determine conditions.

Note

Unlike other hooks, entry hooks can not yield. They are evaluated immediately and the return value is evaluated for its “truthiness”.

import random
from laminar import Flow, Layer
from laminar.configuration import hooks

@BranchFlow.register
class A(Layer):
    def __call__(self) -> None:
        self.foo = random.random()

@BranchFlow.register
class B(Layer):
    def __call__(self, a: A) -> None:
        self.foo = a.foo

    @hooks.entry
    def random_foo(self, a: A) -> bool:
        return a.foo <= .5

@BranchFlow.register
class C(Layer):
    def __call__(self, a: A) -> None:
        self.foo = a.foo

@BranchFlow.register
class D(Layer):
    def __call__(self, b: B, c: C) -> None: ...
stateDiagram-v2 state BranchFlow { direction LR A --> B A --> C B --> D C --> D }

In this Flow, 50% of the time B will be executed and the other 50% it will be skipped. Notice that like Layer.__call__, entry hooks can also use layers as parameters in order to evaluate complex conditions.

Consequently, 50% of the time D will also be skipped. This is because by default layers will be executed only if all layers it depends on are executed. Entire subtrees will potentially be skipped if even if a single Layer is set to be skipped.

We can prevent this from occuring by ending the conditional branch:

@BranchFlow.register
class D(Layer):
    def __call__(self, b: B, c: C) -> None: ...

    @hooks.entry
    def always_true(self) -> bool:
        return True

Now regardless of whether B is executed, D will always execute. This implies that not only can every layer can have individual execution conditions, but also every Flow branch. This enables flows to be extremely flexible in their execution.

But if D always executes, how do we know when B does?

State

Layer.state is a property that returns a State object that can evaluate the state that a layer is currently in. State.finished will tell you whether or not a Layer has been finished. With this logic we can extend D.

@BranchFlow.register
class D(Layer):
    def __call__(self, b: B, c: C) -> None:
        self.foo = b.foo if b.state.finished else c.foo

    @hooks.entry
    def always_true(self) -> bool:
        return True

D now uses the value from B.foo if B was finished, else it uses the value from C.foo.