Branching¶
Sometimes one record should be processed in more than one way. Branch sends every
record down several named branch paths, and JoinBranches merges the results back
into one record.
The usual reason is comparison: the same question answered by two prompts, or by two served models, ending up side by side in one record. Preference data is built this way.
At a glance¶
| Piece | What it does |
|---|---|
Branch(name=step, ...) |
runs every path on every record and tags the outputs |
JoinBranches() |
groups the tagged records back into one record per input |
_branch_id, _branch_name, _branch_input_keys |
the columns Branch adds for JoinBranches to read |
| default suffix | _{path name}, on every column a path added |
how="inner" |
drop an input record unless every path produced something |
how="outer" |
keep it; the missing path adds no columns |
A Branch must be followed by a JoinBranches. compile() rejects a pipeline where it
is not.
Branch¶
Branch(**paths)¶
| Parameter | Type | Default | Meaning |
|---|---|---|---|
paths |
**Step |
at least two | named steps, given as keyword arguments |
Each keyword is a path name, and each value is a step — or a whole pipeline, built with
>>. Every input record is sent down every path.
from datafast import Branch, JoinBranches, Map, Sink, Source
pipeline = (
Source.list([{"text": "hello"}, {"text": "goodbye"}])
>> Branch(
upper=Map(lambda r: {**r, "shout": r["text"].upper()}),
length=Map(lambda r: {**r, "shout": str(len(r["text"]))}),
)
>> JoinBranches()
>> Sink.list()
)
records = pipeline.run()
# [{'text': 'hello', 'shout_upper': 'HELLO', 'shout_length': '5'}, ...]
Fewer than two paths raises ValueError at construction.
Paths run one after another, not at the same time. Each path gets a deep copy of the records, so one path cannot change what another path sees.
The tagging columns¶
Branch adds three columns so that JoinBranches can put the records back together:
| Column | Value |
|---|---|
_branch_id |
the position of the input record, shared by every path |
_branch_name |
the name of the path that produced this record |
_branch_input_keys |
the column names the record had before the branch |
from datafast import Branch, Map
branch = Branch(
upper=Map(lambda r: {**r, "shout": r["text"].upper()}),
length=Map(lambda r: {**r, "shout": str(len(r["text"]))}),
)
tagged = list(branch.process(iter([{"text": "hello"}])))
# [{'text': 'hello', '_branch_id': 0, '_branch_input_keys': ['text'],
# 'shout': 'HELLO', '_branch_name': 'upper'}, ...]
_branch_input_keys is what tells JoinBranches which columns are old and which ones
the path added. A path that rebuilds a record from scratch and drops _branch_id loses
its records at the join; a warning is logged when that happens.
JoinBranches removes all three columns, so they do not reach the sink.
JoinBranches¶
JoinBranches(suffixes=None, how="inner")¶
| Parameter | Type | Default | Meaning |
|---|---|---|---|
suffixes |
dict[str, str] \| None |
None |
per-path column suffix; _{path name} for any path not listed |
how |
str |
"inner" |
"inner" or "outer"; what to do when a path produced nothing |
Any other value of how raises ValueError at construction.
Records are grouped by _branch_id, and each group becomes one output record:
- Columns that existed before the branch are copied over once, with no suffix.
- Columns a path added get that path's suffix.
- The three
_branch_*columns are dropped.
from datafast import Branch, JoinBranches, Map, Sink, Source
pipeline = (
Source.list([{"q": "hi"}])
>> Branch(
a=Map(lambda r: {**r, "answer": "short"}),
b=Map(lambda r: {**r, "answer": "long"}),
)
>> JoinBranches(suffixes={"a": "_first"})
>> Sink.list()
)
records = pipeline.run()
# [{'q': 'hi', 'answer_first': 'short', 'answer_b': 'long'}]
Path a uses the suffix given; path b was not listed, so it falls back to _b.
The cartesian join¶
A path may produce more than one record per input — an LLM step with several outputs per
record, or a FlatMap. When that happens the merge is a cartesian product: every
record from one path is paired with every record from the others.
from datafast import Branch, FlatMap, JoinBranches, Sink, Source
pipeline = (
Source.list([{"n": 1}])
>> Branch(
a=FlatMap(lambda r: [{**r, "a": 1}, {**r, "a": 2}]),
b=FlatMap(lambda r: [{**r, "b": 10}, {**r, "b": 20}]),
)
>> JoinBranches()
>> Sink.list()
)
records = pipeline.run()
assert len(records) == 4 # 2 x 2
Two paths of three records each give nine output records for one input record. This grows fast, so keep it in mind before asking two paths for several outputs apiece.
Missing paths: inner and outer¶
A path can also produce no record for an input — a Filter inside the path dropped
it, or an LLM call failed.
how="inner"(the default) drops that_branch_identirely. Every output record is complete.how="outer"keeps it. The missing path simply contributes no columns, so the output record has fewer columns than the others.
from datafast import Branch, Filter, JoinBranches, Map, Sink, Source
pipeline = (
Source.list([{"n": 1}, {"n": 2}])
>> Branch(
keep=Map(lambda r: {**r, "a": r["n"]}),
picky=Filter(lambda r: r["n"] == 1) >> Map(lambda r: {**r, "b": r["n"]}),
)
>> JoinBranches(how="outer")
>> Sink.list()
)
records = pipeline.run()
# [{'n': 1, 'a_keep': 1, 'b_picky': 1}, {'n': 2, 'a_keep': 2}]
Note that the second record has no b_picky key at all. It is absent, not None. Code
reading that column must use record.get("b_picky").
Inside the runner¶
When a pipeline is run, the runner drives the paths itself instead of leaving Branch
to do it. That matters for three reasons.
Nested batching. An LLM step inside a path gets the same batching and execution strategy as one at the top level. Four records against one path go out as one batch of four calls, not as four separate batches.
Checkpoint files per path. The whole Branch is one step in the manifest, but each
path writes its own checkpoint file, keyed by a dotted name:
step_001_Branch.jsonl the tagged output of the whole step
step_001_Branch.a.jsonl path 'a'
step_001_Branch.b.1_LLMStep.jsonl the LLM step at position 1 of path 'b'
The name of the Branch step is in every one of those file names, so
Branch(...).as_step("compare") renames them too.
Resume. A path that finished before the crash is read back from its file rather than run again, and a path that crashed part way through only re-runs the LLM calls it had not completed. This is the same per-call resume LLM steps get at the top level; see Pipelines & execution.
Path steps must be deterministic¶
Non-LLM steps inside a path are not checkpointed. On resume they run again, to rebuild the records that the path's LLM step was working on.
A completed LLM call is matched to its record by position. So if a step inside the path yields the records in a different order the second time, or yields a different number of them, the saved results are attached to the wrong records — quietly.
In practice: give any Sample inside a path a seed, and keep the functions you pass to
Map and Filter free of randomness and of clocks.
The fingerprint that protects a checkpoint records the path names and the step classes
inside them. Changing the function inside a Map does not change it, so nothing will warn
you. Changing which steps a path contains does change it, and resuming then raises
PipelineChangedError.
What compile() rejects¶
compile() checks the branch structure before anything runs, and raises
PipelineValidationError:
| Shape | Why |
|---|---|
a Branch with no JoinBranches after it |
the tagging columns would reach the sink |
a JoinBranches with no Branch before it |
there is nothing to merge |
a second Branch opened before the first is closed |
same as the first case |
a Branch inside a branch path |
the inner branch overwrites the outer tagging columns, and every record is then dropped at the join |
A branch path may not contain a source (it would throw away the records the branch fed it) and may not contain a sink. Both are rejected the same way.
To branch twice, close the first branch first:
from datafast import Branch, JoinBranches, Map, Sink, Source
pipeline = (
Source.list([{"n": 1}])
>> Branch(a=Map(lambda r: {**r, "x": 1}), b=Map(lambda r: {**r, "x": 2}))
>> JoinBranches()
>> Branch(c=Map(lambda r: {**r, "y": 1}), d=Map(lambda r: {**r, "y": 2}))
>> JoinBranches()
>> Sink.list()
)
pipeline.compile()
Things worth knowing¶
- A path that changes an existing column loses the change. A column that existed
before the branch is copied from the first path that produced a record, and is never
suffixed. If two paths rewrite
text, only the first path'stextsurvives the join. Write to a new column instead. - Suffixes are appended, not inserted.
responsein pathchosenbecomesresponse_chosen. Columns an LLM step adds are suffixed too, so_modelbecomes_model_chosen. innerhides failures. One path failing on a record removes that record from the dataset with only a log line. Compare the record count in and out if that matters.- Paths are sequential. Two paths take about as long as running them one after the other, because that is what happens. Branching is for shaping the data, not for speed.
- Two paths double the cost. Each path makes its own LLM calls on every record.
- Path names become column names. Keep them short and valid as a suffix.
Where to go next¶
- Key Concepts — records, steps, the runner, and checkpoints.
- Full Glossary — the precise meaning of branch path, record and column.
- Source and Seed — the steps a pipeline starts with.
- Data ops —
Map,Filterand the other transforms a path is made of. - Pipelines & execution — resume, and the per-call progress a path relies on.
- API reference — generated signatures for both steps.