Parallel branches¶
Dispatch M heterogeneous subgraphs concurrently, projected outputs merged back into the parent via the parent's reducers in branch insertion order.
Sibling to fan-out (same for each thing, do work in
parallel shape), but the thing is different per branch: a research
subgraph, a categorize subgraph, a sentiment subgraph — each with its
own state schema, its own middleware, its own observer events —
running in parallel and joining their results into one parent state.
When to reach for parallel branches¶
The signal: a fixed set of named operations, each with its own behavior and state schema, that don't depend on each other. Three classifiers running independently against the same input. A research step, a translate step, and a fact-check step that all want the parent's prompt. M is known at build time and small (typically 2–6), and each branch is its own subgraph because each has its own internal pipeline worth modelling separately.
Fan-out is the right pick when you have N similar pieces of work, N depends on runtime state, and the work is the same across instances. Parallel branches is the right pick when M is a small fixed set of different operations that happen to run concurrently.
The shape¶
from openarmature.graph import BranchSpec, GraphBuilder
builder.add_parallel_branches_node(
"dispatcher",
branches={
"research": BranchSpec(
subgraph=research_subgraph, # CompiledGraph[ResearchState]
inputs={"question": "prompt"}, # subgraph_field -> parent_field
outputs={"facts": "facts"}, # parent_field -> subgraph_field
),
"translate": BranchSpec(
subgraph=translate_subgraph, # CompiledGraph[TranslateState]
inputs={"source": "prompt"},
outputs={"translation": "translated"},
),
"fact_check": BranchSpec(
subgraph=fact_check_subgraph, # CompiledGraph[FactCheckState]
inputs={"claim": "prompt"},
outputs={"verdict": "verdict"},
),
},
error_policy="fail_fast", # or "collect"
)
Each branch's subgraph is a compiled graph; inputs and outputs
mirror the explicit projection shape from
composition. The
branches dict's key is the branch name — used as the branch identity
on observer events (see observability) and in
the per-branch error records that error_policy: "collect"
produces.
Per-branch state, inputs and outputs¶
Each branch runs its own subgraph against its own state — heterogeneous
schemas are explicit. Subgraph fields named in inputs are seeded
from the parent's corresponding field at branch entry; other subgraph
fields take their schema defaults. At branch exit, only the parent
fields named in outputs receive contributions; the rest of the
branch's final state is discarded.
When two branches contribute to the same parent field, the parent's
reducer for that field applies both values in branch insertion
order — first the branch declared first in the branches dict,
then the next, and so on. This is deterministic regardless of which
branch's inner work finishes first.
Error policy¶
"fail_fast"(default): the first branch failure cancels the in-flight siblings and propagates asParallelBranchesBranchFailed(aNodeExceptionsubtype) carrying the failingbranch_nameand the original cause as__cause__.recoverable_stateis the parent's snapshot at the moment the dispatcher entered — no buffered branch contributions are applied, including those of branches that successfully completed before the failure. Buffer-and-apply semantics: contributions are held until every branch finishes, then either all apply (success) or none apply (fail_fast failure)."collect": every branch runs to completion. Successful branches' contributions merge in insertion order; failed branches'outputsprojections do NOT fire (their named parent fields stay at their defaults). If you declareerrors_fieldon the dispatcher, each failed branch produces a record with at minimum{"branch_name": <name>, "category": <category>}appended to that parent list field; the implementation may include additional keys (message, cause_type) and tests should match by the spec-mandated keys rather than strict equality.
Branch middleware¶
Each BranchSpec accepts a middleware tuple — middlewares that
wrap that branch's whole subgraph invocation as a unit. Retry
middleware on a branch retries the whole branch: a fresh
subgraph invocation each time, fresh inner-node execution. The
wrapping retry's attempt counter propagates to events emitted from
inner nodes (per graph-engine §6 v0.16.1), so observer events
inside the branch correctly show attempt_index ticking across
retries.
Branch middleware is independent across branches — branch A may
have [retry, timing]; branch B may have []; branch C may have
some custom breaker. Each branch's chain composes in isolation.
Composition with other constructs¶
Parallel branches compose with the rest of the engine the way subgraphs and fan-outs do:
- A branch's subgraph can itself contain a fan-out node — inner-node
events inside that fan-out carry both
branch_name(this branch) andfan_out_index(the instance within this branch). The two fields are independent. - The parallel-branches node itself can be invoked from inside a
fan-out instance — inner events then carry the outer fan-out's
fan_out_indexand the inner branch'sbranch_name. - Per-graph and per-node middleware on the parallel-branches node
wrap the dispatcher as a single unit — one
startedevent before dispatch begins, onecompletedevent after all branches finish and fan-in lands. The parent's retry middleware retries the whole parallel-branches node, not individual branches.
Resume semantics¶
Parallel-branches nodes use the same atomic restart model as fan-out (per spec §10.7): if a checkpoint resume lands on a parallel-branches node, all branches re-dispatch from scratch. Per-branch progress is not individually persisted in v1.
When parallel branches is NOT the right shape¶
- Not the same as N copies of one subgraph. If you want "run this subgraph for each item in a list," reach for fan-out.
- Not a router. A router is a conditional-edge pattern — pick one branch based on state. Parallel branches runs all branches concurrently.
- Not a coordinator. Branches don't communicate with each other during execution; if branch B's work depends on branch A's output, you want a linear pipeline (A → B), not parallel branches.