|
| 1 | +# Parallel branches |
| 2 | + |
| 3 | +Dispatch M heterogeneous subgraphs concurrently, projected outputs |
| 4 | +merged back into the parent via the parent's reducers in branch |
| 5 | +insertion order. |
| 6 | + |
| 7 | +Sibling to [fan-out](fan-out.md) (same `for each thing, do work in |
| 8 | +parallel` shape), but the *thing* is different per branch: a research |
| 9 | +subgraph, a categorize subgraph, a sentiment subgraph — each with its |
| 10 | +own state schema, its own middleware, its own observer events — |
| 11 | +running in parallel and joining their results into one parent state. |
| 12 | + |
| 13 | +## When to reach for parallel branches |
| 14 | + |
| 15 | +The signal: a fixed set of named operations, each with its own |
| 16 | +behavior and state schema, that don't depend on each other. Three |
| 17 | +classifiers running independently against the same input. A research |
| 18 | +step, a translate step, and a fact-check step that all want the |
| 19 | +parent's prompt. M is known at build time and small (typically 2–6), |
| 20 | +and each branch is its own subgraph because each has its own |
| 21 | +internal pipeline worth modelling separately. |
| 22 | + |
| 23 | +Fan-out is the right pick when you have N similar pieces of work, |
| 24 | +N depends on runtime state, and the work is the same across instances. |
| 25 | +Parallel branches is the right pick when M is a small fixed set of |
| 26 | +different operations that happen to run concurrently. |
| 27 | + |
| 28 | +## The shape |
| 29 | + |
| 30 | +```python |
| 31 | +from openarmature.graph import BranchSpec, GraphBuilder |
| 32 | + |
| 33 | +builder.add_parallel_branches_node( |
| 34 | + "dispatcher", |
| 35 | + branches={ |
| 36 | + "research": BranchSpec( |
| 37 | + subgraph=research_subgraph, # CompiledGraph[ResearchState] |
| 38 | + inputs={"question": "prompt"}, # subgraph_field -> parent_field |
| 39 | + outputs={"facts": "facts"}, # parent_field -> subgraph_field |
| 40 | + ), |
| 41 | + "translate": BranchSpec( |
| 42 | + subgraph=translate_subgraph, # CompiledGraph[TranslateState] |
| 43 | + inputs={"source": "prompt"}, |
| 44 | + outputs={"translation": "translated"}, |
| 45 | + ), |
| 46 | + "fact_check": BranchSpec( |
| 47 | + subgraph=fact_check_subgraph, # CompiledGraph[FactCheckState] |
| 48 | + inputs={"claim": "prompt"}, |
| 49 | + outputs={"verdict": "verdict"}, |
| 50 | + ), |
| 51 | + }, |
| 52 | + error_policy="fail_fast", # or "collect" |
| 53 | +) |
| 54 | +``` |
| 55 | + |
| 56 | +Each branch's `subgraph` is a compiled graph; `inputs` and `outputs` |
| 57 | +mirror the explicit projection shape from |
| 58 | +[composition](composition.md#explicitmapping-declarative). The |
| 59 | +branches dict's key is the branch name — used as the branch identity |
| 60 | +on observer events (see [observability](observability.md)) and in |
| 61 | +the per-branch error records that `error_policy: "collect"` |
| 62 | +produces. |
| 63 | + |
| 64 | +## Per-branch state, inputs and outputs |
| 65 | + |
| 66 | +Each branch runs its own subgraph against its own state — heterogeneous |
| 67 | +schemas are explicit. Subgraph fields named in `inputs` are seeded |
| 68 | +from the parent's corresponding field at branch entry; other subgraph |
| 69 | +fields take their schema defaults. At branch exit, only the parent |
| 70 | +fields named in `outputs` receive contributions; the rest of the |
| 71 | +branch's final state is discarded. |
| 72 | + |
| 73 | +When two branches contribute to the same parent field, the parent's |
| 74 | +reducer for that field applies both values in **branch insertion |
| 75 | +order** — first the branch declared first in the `branches` dict, |
| 76 | +then the next, and so on. This is deterministic regardless of which |
| 77 | +branch's inner work finishes first. |
| 78 | + |
| 79 | +## Error policy |
| 80 | + |
| 81 | +- **`"fail_fast"`** (default): the first branch failure cancels |
| 82 | + the in-flight siblings and propagates as |
| 83 | + `ParallelBranchesBranchFailed` (a `NodeException` subtype) carrying |
| 84 | + the failing `branch_name` and the original cause as `__cause__`. |
| 85 | + `recoverable_state` is the parent's snapshot at the moment the |
| 86 | + dispatcher entered — **no buffered branch contributions are |
| 87 | + applied**, including those of branches that successfully completed |
| 88 | + before the failure. Buffer-and-apply semantics: contributions are |
| 89 | + held until every branch finishes, then either all apply (success) |
| 90 | + or none apply (fail_fast failure). |
| 91 | +- **`"collect"`**: every branch runs to completion. Successful |
| 92 | + branches' contributions merge in insertion order; failed branches' |
| 93 | + `outputs` projections do NOT fire (their named parent fields stay |
| 94 | + at their defaults). If you declare `errors_field` on the dispatcher, |
| 95 | + each failed branch produces a record with at minimum |
| 96 | + `{"branch_name": <name>, "category": <category>}` appended to that |
| 97 | + parent list field; the implementation may include additional keys |
| 98 | + (message, cause_type) and tests should match by the spec-mandated |
| 99 | + keys rather than strict equality. |
| 100 | + |
| 101 | +## Branch middleware |
| 102 | + |
| 103 | +Each `BranchSpec` accepts a `middleware` tuple — middlewares that |
| 104 | +wrap that branch's whole subgraph invocation as a unit. Retry |
| 105 | +middleware on a branch retries the **whole branch**: a fresh |
| 106 | +subgraph invocation each time, fresh inner-node execution. The |
| 107 | +wrapping retry's attempt counter propagates to events emitted from |
| 108 | +inner nodes (per graph-engine §6 v0.16.1), so observer events |
| 109 | +inside the branch correctly show `attempt_index` ticking across |
| 110 | +retries. |
| 111 | + |
| 112 | +Branch middleware is independent across branches — branch A may |
| 113 | +have `[retry, timing]`; branch B may have `[]`; branch C may have |
| 114 | +some custom breaker. Each branch's chain composes in isolation. |
| 115 | + |
| 116 | +## Composition with other constructs |
| 117 | + |
| 118 | +Parallel branches compose with the rest of the engine the way |
| 119 | +subgraphs and fan-outs do: |
| 120 | + |
| 121 | +- A branch's subgraph can itself contain a fan-out node — inner-node |
| 122 | + events inside that fan-out carry **both** `branch_name` (this |
| 123 | + branch) and `fan_out_index` (the instance within this branch). |
| 124 | + The two fields are independent. |
| 125 | +- The parallel-branches node itself can be invoked from inside a |
| 126 | + fan-out instance — inner events then carry the outer fan-out's |
| 127 | + `fan_out_index` and the inner branch's `branch_name`. |
| 128 | +- Per-graph and per-node middleware on the parallel-branches node |
| 129 | + wrap the dispatcher as a single unit — one `started` event before |
| 130 | + dispatch begins, one `completed` event after all branches finish |
| 131 | + and fan-in lands. The parent's retry middleware retries the **whole |
| 132 | + parallel-branches node**, not individual branches. |
| 133 | + |
| 134 | +## Resume semantics |
| 135 | + |
| 136 | +Parallel-branches nodes use the same **atomic restart** model as |
| 137 | +fan-out (per spec §10.7): if a checkpoint resume lands on a |
| 138 | +parallel-branches node, all branches re-dispatch from scratch. |
| 139 | +Per-branch progress is not individually persisted in v1. |
| 140 | + |
| 141 | +## When parallel branches is NOT the right shape |
| 142 | + |
| 143 | +- **Not the same as N copies of one subgraph.** If you want "run |
| 144 | + this subgraph for each item in a list," reach for |
| 145 | + [fan-out](fan-out.md). |
| 146 | +- **Not a router.** A router is a conditional-edge pattern — pick |
| 147 | + one branch based on state. Parallel branches runs *all* branches |
| 148 | + concurrently. |
| 149 | +- **Not a coordinator.** Branches don't communicate with each other |
| 150 | + during execution; if branch B's work depends on branch A's |
| 151 | + output, you want a linear pipeline (A → B), not parallel branches. |
0 commit comments