Skip to content

Repeated Blocks and Fan-In/Fan-Out

loman.util provides graph-building helpers for computations that repeat the same block for a collection of keys. The helpers add ordinary Loman nodes and dependencies: they do not read values or execute calculations while building the graph.

Defining repeated blocks

Suppose one input dataframe contains data for several instruments and the same calculation should run independently for each instrument:

import pandas as pd

from loman import Computation, util


price_block = Computation()
price_block.add_node("data")
price_block.add_node("value", lambda data: data.assign(value=data["quantity"] * data["price"]))


def select_instrument(frame, instrument_id):
    return frame.loc[[instrument_id]]


def concat_values(values):
    return pd.concat(values, names=["instrument_id"])


comp = Computation()
comp.add_node("positions")

repeated = util.RepeatedBlocks(
    block=price_block,
    keys=("AAPL", "MSFT"),
    base_path="instruments",
    features=[
        util.FanOut("positions", "data", transform=select_instrument),
        util.FanIn("value", "portfolio_values", combine=concat_values),
    ],
)
built = repeated.add_to(comp)

This creates two block paths, instruments/AAPL and instruments/MSFT. select_instrument(value, key) runs separately for each block input, and concat_values(values) receives the ordered mapping:

{
    "AAPL": comp.v["instruments/AAPL/value"],
    "MSFT": comp.v["instruments/MSFT/value"],
}

The callbacks run only when their nodes are computed. Existing Loman behavior therefore remains intact: unneeded branches are lazy, changed inputs invalidate their descendants, and independent blocks can use configured executors.

For process executors, transform and combine must be pickleable, just like ordinary node functions. The generated adapter nodes use the computation's default executor.

built.blocks maps each key to its generated block path, built.nodes lists every generated node in declaration order, and built.named maps the names features chose to label — a FanIn labels its result — to the nodes created for them.

Features

features is an ordered list describing how data flows in and out of each copy. Four are built in:

feature what it creates
FanOut(source, target, transform=None) one node per block, fed from outside
FanIn(source, result, combine=None) one outer node gathering a node from every block
IdNode(name) one node per block holding that block's key
InputValue(name, value) one shared outer node, linked into every block

Positional(fn) wraps an aggregator that takes its values positionally, so a combine written before the keyed mapping existed does not need a lambda at every call site: FanIn("value", "total", combine=Positional(df_hconcat)). It discards the keys. A keyed aggregator can use them instead — for dataframes, lambda m: pd.concat(m, axis=1) turns them into column labels — but that is a different result, not a drop-in replacement: it adds an outer level to the column index where a flat positional concatenation does not.

Features are planned in the order given, and a later feature may read a node an earlier one created. Nothing is applied until all of them have been planned and checked together, so a definition that fails validation leaves the computation completely untouched — no partially built blocks.

Which names are relative to base_path

This differs by feature, and it is worth knowing before you go looking for a node that is not where you expected:

name resolves to
FanOut.target, FanIn.source, IdNode.name inside each block, <base_path>/<key>/<name>
InputValue.name beside the blocks, <base_path>/<name>
FanOut.source the outer computation, verbatim
FanIn.result the outer computation, verbatim

So with base_path="instruments", FanIn("value", "portfolio_values") creates a top-level portfolio_values, not instruments/portfolio_values. That is deliberate — an aggregate usually belongs wherever the rest of the model expects it, rather than being forced under the blocks it happens to be computed from — but it is the opposite of InputValue, whose shared node is placed under base_path so that two definitions cannot collide. built.named always reports the key that was actually created.

Feeding a node the template does not declare

FanOut.target must be a name the block template declares or refers to. A name it never mentions is rejected, because that is usually a typo which would otherwise add a dead node to every block. Where it is deliberate — injecting a node the template itself has no use for — pass create=True:

util.FanOut("leverage_source", "leverage", create=True)

The guard that matters still applies: a fan-out will not replace a calculation, with or without create.

InputValue takes the same create flag, and defaults the same way. IdNode defaults to create=True instead, because creating the node is its whole job — a template that never mentions the name is the ordinary case there, not a mistake. The cost is that a misspelled IdNode("labl") adds a node nothing reads and leaves the real label unfilled; validate() reports that as an uninitialized input, but only once the graph is built. Pass create=False where the template does declare the node, to have the misspelling rejected at definition time.

Note that the low-level add_fan_out is more permissive, because it takes target node names directly and has no template to check them against. If you are comparing the two, that is the difference.

Templates built by a computation factory

A @ComputationFactory class makes a perfectly good template — build it, then pass the computation:

util.RepeatedBlocks(PositionsComputation(), keys, "positions", features=[...])

One thing to watch: a factory commonly gives inputs a default with input_node(value=2). Because keep_values is False, those defaults are not carried into the copies, so the blocks stay uninitialized until the input is supplied. Pass keep_values=True to keep them, or feed the input with an InputValue or a FanOut.

Reading a different node per key

The first example slices one shared positions node. When each block should instead read from a different node that already exists, pass a callable as the FanOut source. It is applied to each key to resolve that block's source node:

comp.add_node("data/AAPL", value=aapl_frame)
comp.add_node("data/MSFT", value=msft_frame)

util.RepeatedBlocks(
    block=price_block,
    keys=("AAPL", "MSFT"),
    base_path="instruments",
    features=[util.FanOut(lambda key: f"data/{key}", "data")],
).add_to(comp)

instruments/AAPL/data now depends only on data/AAPL, so inserting a new value for one instrument invalidates only that block.

Because a callable source is meaningful, a callable passed anywhere a plain node name is expected raises TypeError rather than silently creating a node keyed by the function object.

Giving each block its own key

IdNode creates a node inside every block holding that block's key, so block functions can depend on their own key by name:

block = Computation()
block.add_node("label")
block.add_node("data")
block.add_node("summary", lambda label, data: f"{label}: {data.sum()}")

util.RepeatedBlocks(
    block=block,
    keys=("AAPL", "MSFT"),
    base_path="instruments",
    features=[
        util.IdNode("label"),
        util.FanOut("positions", "data", transform=select_instrument),
    ],
).add_to(comp)

instruments/AAPL/label holds "AAPL". These nodes have no predecessors — each simply holds its key as a value. The template does not have to declare the node; if it does, it must be an input node.

This is the natural way to let a block look data up by its own key, or branch on it, without threading the key in from outside.

Sharing a value across every block

Blocks are copied structure-first: keep_values defaults to False, so the values currently held by the template are not carried into the generated copies. This is the opposite of Computation.add_block, which defaults to True. The defaults differ because the two calls do different jobs. add_block adds one specific block, often a sub-model that has already been populated or calibrated and would not compute without its values. The repeated-block utilities stamp out many copies of one template, where whatever the template happened to hold when it was last run is rarely what all of the copies should start from.

When every copy does need the same value, use InputValue, which creates one node beside the blocks and links it into each of them:

util.RepeatedBlocks(
    block=price_block,
    keys=("AAPL", "MSFT"),
    base_path="instruments",
    features=[util.InputValue("scale", 100)],
).add_to(comp)

Every block now reads scale from instruments/scale, so changing it is a single comp.insert("instruments/scale", 10) rather than an insert into each generated copy. Use a FanOut with no transform to broadcast a node that already exists elsewhere, and keep_values=True only when the copies really should start from a snapshot of the template.

Writing your own feature

A feature is any object with a plan method. It never changes the computation: it describes the nodes it wants as PlannedNode values, and the builder validates every feature's plan together before applying any of them.

from loman import PlannedNode


class Doubled:
    """Add <block>/doubled = 2 * <block>/<source> to every block."""

    def __init__(self, source, name="doubled"):
        self.source = source
        self.name = name

    def plan(self, ctx):
        source = ctx.require_block_node(self.source, "Doubled source")
        for block_path in ctx.blocks.values():
            yield PlannedNode.calc(
                block_path.join(self.name),
                lambda value: value * 2,
                (block_path.join(source),),
            )

ctx is a BlockContext. It carries blocks, mapping each key to its block path, along with the template and the destination computation, and offers require_block_node and require_block_input so port checks raise the same errors the built-in features do.

PlannedNode has three constructors: input_node(key, value) for a node holding a value, link(key, source) for a node that copies another unchanged, and calc(key, func, args) for a calculation, where each argument is either a node key to depend on or a C(...) constant. Pass label= to have the node appear in built.named.

Low-level helpers

The features are built on independent utilities, which can also be used directly for more dynamic graph construction.

Repeated blocks

blocks = util.add_repeated_blocks(
    comp,
    price_block,
    ["AAPL", "MSFT"],
    base_path="instruments",
)

The return value maps each original key to its generated NodeKey. Keys become real path parts, so non-string identifiers are supported in memory. JSON serialization currently converts node path parts to strings, so use string keys when serialized computations must preserve key types. Values from the block template are not copied by default; pass keep_values=True when the repeated instances should retain them.

Fan-out

util.add_fan_out(
    comp,
    source="positions",
    targets={key: path / "data" for key, path in blocks.items()},
    transform=select_instrument,
)

With no transform, the source value is broadcast unchanged. With a transform, each target is calculated as transform(source_value, key). Passing a callable as source resolves a source node per key instead of broadcasting one.

Identifier nodes

util.add_id_nodes(comp, blocks, "label")

Adds one node per block holding that block's key, and returns a mapping from each key to the generated node.

Fan-in

util.add_fan_in(
    comp,
    result="portfolio_values",
    sources={key: path / "value" for key, path in blocks.items()},
    combine=concat_values,
)

The combine function receives an insertion-ordered mapping from keys to source values. This supports dataframe concatenation as well as scalar reductions:

util.add_fan_in(
    comp,
    result="total_value",
    sources={key: path / "value" for key, path in blocks.items()},
    combine=lambda values: sum(values.values()),
)

If combine is omitted, the keyed mapping itself becomes the result value. Note that it receives the mapping, not the values, so combine=sum would add up the keys — use lambda values: sum(values.values()).

Repeated blocks in a computation factory

repeated_blocks declares the same structure inside a @ComputationFactory class, alongside input_node, calc_node and block. The attribute name becomes the base path, so the class below generates instruments/AAPL and instruments/MSFT:

from loman import ComputationFactory, FanIn, FanOut, IdNode, calc_node, input_node, repeated_blocks


@ComputationFactory
class InstrumentBlock:
    label = input_node()
    data = input_node()

    @calc_node
    def value(self, label, data):
        return data.assign(instrument=label, value=data["quantity"] * data["price"])


@ComputationFactory
class Portfolio:
    positions = input_node()

    def select_instrument(self, positions, instrument_id):
        return positions.loc[[instrument_id]]

    instruments = repeated_blocks(
        InstrumentBlock,
        keys=("AAPL", "MSFT"),
        features=[
            IdNode("label"),
            FanOut("positions", "data", transform=select_instrument),
            FanIn("value", "portfolio_values", combine=concat_values),
        ],
    )

    @calc_node
    def total_value(self, portfolio_values):
        return portfolio_values["value"].sum()


comp = Portfolio()

The block may be a Computation or, as above, another computation factory, matching block. keep_values is accepted too.

Nodes that features refer to can be declared anywhere in the class, regardless of order: a node that is only referred to remains a placeholder until the member that defines it is added. A name that another member defines, however, cannot also be a fan-in result — declaring both portfolio_values = input_node() and a FanIn(..., "portfolio_values") is an error.

Callbacks follow the same self convention as calc_node. select_instrument above is declared with self as its first parameter and is bound to the definition object, so it is called as select_instrument(positions, key) and can use other methods and attributes of the class. This applies to a FanOut source resolver and a FanIn combine function too. Callbacks that do not take self — module-level functions, lambdas, or staticmethods — are used unchanged. Pass ignore_self=False to @ComputationFactory to disable binding for the whole class.

Serialization

The generated wiring nodes roundtrip through JSON. A fan-out transform and a fan-in combine function are passed as constant arguments, and constants are recorded in the serialized node, so a fan-out or fan-in node can be recalculated after a reload. As with any node function, the callback must be importable — a module-level function, or any callable when ComputationSerializer(use_dill_for_functions=True) is used. A constant that cannot be encoded raises SerializationError naming the node, rather than being dropped and failing later.

What still does not survive is the block template's own calculations. Computation.add_block deliberately sets serialize=False on the nodes it copies, so their functions are not retained:

block.add_node("doubled", double)  # calculated inside every generated block

After a roundtrip, instruments/AAPL/doubled keeps its stored value but has no function, so it cannot be recalculated and anything downstream of it stays stale. If your per-key work happens in the template — the normal case — serialize computed values when a snapshot is enough, and rebuild the graph from its Python definition when you need to recalculate.

Keys become real path parts, and JSON converts path parts to strings, so use string keys when a serialized computation must preserve key types.