Blocks¤
In the f3dasm framework, every component of the data-driven process is encapsulated as a Block. A block is an object designed to work with an ExperimentData instance. When invoked, it processes the data within the ExperimentData instance and produces a new ExperimentData instance. By chaining different blocks, you can construct a complete data-driven pipeline.
The block base class looks like this:
class Block(ABC):
def arm(self, data: ExperimentData) -> None:
pass
@abstractmethod
def call(self, data: ExperimentData, **kwargs) -> ExperimentData:
...
To create a new block, subclass the Block class and implement the call method. This method is executed when the block is invoked, accepting any keyword arguments and returning an ExperimentData instance. Before the call method runs, the arm method is used to equip the block with the ExperimentData instance it will process.
class CustomBlock(Block):
def call(self, data: ExperimentData, **kwargs) -> ExperimentData:
...
# Any method that manipulates the experiments
...
return data
To start the data-driven process, you create an ExperimentData instance and a CustomBlock instance and call the call method of the block with the ExperimentData:
# Create the ExperimentData instance
experiment_data = ExperimentData(domain=..., input_data=...)
# Create the CustomBlock instance
custom_block = CustomBlock()
# Start the data-driven process
resulting_data = custom_block.call(experiment_data)
Composing blocks¤
Blocks are designed to be composed. There are two primitives for composition that cover nearly every workflow: the >> operator and the LoopBlock.
Chaining with >>¤
block_a >> block_b returns a ChainedBlock that runs block_a first, then passes its output into block_b. Any number of blocks can be joined this way:
pipeline = block_a >> block_b >> block_c
data = pipeline.call(data)
ChainedBlock is itself a Block, so chains can be chained. Arming a chain arms every block inside it in order.
Repeating with LoopBlock and .loop(n)¤
LoopBlock(block, n_iterations=n) runs an inner block n times, feeding each iteration's output into the next iteration. Every block has a .loop(n) convenience method that wraps it in a LoopBlock:
loop = my_block.loop(50) # LoopBlock repeating my_block 50 times
loop = (step >> data_gen).loop(50) # also works on ChainedBlock
This is the primitive that drives optimization loops — see the Optimization Loops tutorial for the full pattern.
A caveat on **kwargs¤
ChainedBlock.call forwards its **kwargs to every block in the chain. That means you can't chain two blocks whose call-time arguments collide or are incompatible (for example, a sampler's n_samples would leak into the data generator's call). Keep incompatible call signatures in separate .call() invocations.