0075: Parallel-Branches Lightweight Callable Branches and Conditional Branches¶
- Status: Accepted
- Author: Chris Colinsky
- Created: 2026-06-18
- Accepted: 2026-06-18
- Targets: spec/pipeline-utilities/spec.md (§11 Parallel branches — §11.1.1 Branch spec gains an inline-callable branch form (
call, an async function over the parent state returning a partial update) as an alternative to the compiled-subgraphform; §11.4 defines the callable branch's contribution; a new §11.10 Conditional branches adds an optionalwhenpredicate on any branch spec; notes in §11.6 / §11.7 / §11.8 / §11.9 cover how the callable form and skipped branches compose). spec/graph-engine/spec.md (§6 — a callable branch emits its observerstarted/completedpair keyed by itsbranch_name, since it is the unit of work; awhen-skipped branch emits nothing). spec/observability/spec.md (§5.7 — a callable branch renders a branch span under itsbranch_namevia the existing machinery; a skipped branch has no span). Plus new conformance fixtures under spec/pipeline-utilities/conformance/. - Related: 0011 (parallel branches — the primitive this extends), 0044 (parallel-branches dispatch span — the §5.7 / graph-engine
branch_namesurface a callable branch reuses), 0050 / 0065 / 0074 (failure-isolation middleware +catch— reused per-branch for per-leg degrade), 0036 (fan-out collection reducers — the parent-reducer fan-in model §11.4 shares). - Supersedes:
Summary¶
ParallelBranchesNode (§11) requires each branch to be a compiled subgraph with its own state
schema and inputs / outputs projection (§11.1.1). For the common shape "M heterogeneous lightweight
parallel calls over shared state, each independently failure-isolated" — two SQL reads, two API fetches,
hybrid vector + full-text retrieval — that ceremony is too heavy, so consumers drop to a hand-rolled
concurrent-gather plus a bespoke per-result classify, forfeiting the correct cancellation discipline and
the per-leg observer events the primitive already provides.
This proposal extends ParallelBranchesNode rather than adding a third parallel primitive. It lands two
additive features: (1) an inline-callable branch form — a branch can be an async function over the
parent state that returns a partial update, with no subgraph / state schema / projection map; and (2)
conditional branches — an optional when predicate that skips a branch at dispatch when it does not
apply. Everything else the shape needs — concurrent execution (§11.3), fail-fast cancellation (§11.5),
per-branch failure-isolation + FailureIsolatedEvents (§11.7), and reducer fan-in (§11.4) — already
exists and is reused unchanged. Existing subgraph branches are untouched.
Motivation¶
A downstream consumer upgrading onto the native ParallelBranchesNode found three parallel-read sites of
the same shape — fire two independent reads concurrently, both scoped by shared inputs, each degradable on
its own (hybrid recall: vector ∥ full-text; paired DB reads). They evaluated ParallelBranchesNode and
kept a hand-rolled gather each time, because the per-"branch" cost was two state classes + two
single-node compiled subgraphs + two projection maps to replace what is really "call this one function"
— and one leg was conditional (skipped when there is no embedding), which a static branch set cannot
express without an always-run self-no-op branch.
The important point is that ParallelBranchesNode already solves the hard parts the hand-rolled
gather gets wrong: §11.5 has the engine cancel still-running branches correctly (so teardown / cancellation
propagates rather than being swallowed, a footgun of a naive return_exceptions-style gather), and §11.7
branch middleware (a FailureIsolationMiddleware) already emits a per-branch FailureIsolatedEvent on
degrade. So the gather sites are re-deriving — buggily — machinery the primitive has. The only real
barrier is the subgraph-ceremony weight plus the missing conditional. Removing those two makes the
correct, observable primitive adoptable for this shape; it does not add new concurrency, cancellation, or
observability surface.
Detailed design¶
Anticipated bump: MINOR (pre-1.0). Both features are additive — call is an alternative to
subgraph (existing subgraph branches unchanged), and when is optional (absent ⇒ the branch always
dispatches, current behavior). Concrete version is the maintainer's call at acceptance.
§11.1.1 — inline-callable branch form¶
A branch spec's work is given by exactly one of:
subgraph— a compiled subgraph reference (the current form), with its optionalinputs/outputsprojection (§11.2 / §11.4) and its own state schema.call— an async function over the parent state returning a partial update:(parent_state) -> partial_update. No subgraph, no state schema, noinputs/outputs. The function reads the parent state directly and returns parent-shaped fields.
The two forms are mutually exclusive on a single branch (declaring both is a compile-time
parallel_branches_invalid_branch_spec error — a new §11.9 category); a parallel-branches node MAY mix
subgraph branches and callable branches freely. Both forms carry the same optional middleware (§11.7)
and the new when (§11.10). A callable branch is the heterogeneous-parallel analogue of the lightweight
node body — the unit of work is one function, not a graph.
§11.4 — callable-branch contribution¶
A subgraph branch builds its contribution by projecting its exit state through outputs. A callable
branch's contribution is simply the partial update it returns — parent-shaped already, so no projection
step. It is buffered and merged into parent state via the parent's reducer for each field, in branch
insertion order, exactly as §11.4 already specifies for subgraph-branch contributions (the buffer-then-
merge-once-at-completion model, deterministic per §11.8). Parent fields the callable does not return get
no contribution from that branch (partial contributions are first-class per §11.7); references in the
returned update to fields not declared on the parent state are a state_validation_error at merge time,
per the normal §4 / graph-engine contract.
§11.10 — Conditional branches (new)¶
Any branch spec (subgraph or callable) MAY carry an optional when predicate (parent_state) ->
bool, evaluated once at dispatch against the parent state the parallel-branches node received:
whenabsent (default) — the branch always dispatches (current behavior, unchanged).whenreturnstrue— the branch dispatches normally.whenreturnsfalse— the branch is skipped: it is not dispatched, runs no work, contributes nothing to parent state, and emits no observer events and no span (§5.7). It simply does not appear in the run.
The branches mapping and its insertion order are unchanged; skipping is a runtime decision over the
declared set, not a change to the set. Among the branches that do dispatch, §11.8's insertion-order
determinism is unchanged. If every branch is skipped, the parallel-branches node completes as a no-op
(contributes nothing) — this is valid and distinct from the compile-time parallel_branches_no_branches
error (§11.9), which fires only on an empty declared mapping.
when is a deterministic function of dispatch-time parent state, so graph-engine §5 determinism holds:
the same input yields the same skipped set. (A when that consults nondeterministic sources is the same
SHOULD-document caveat §7 already states for conditional middleware.)
§11.7 — failure-isolation on callable branches¶
A callable branch carries middleware like any branch, so per-leg failure-isolation is the existing
§11.7 branch-middleware contract applied to a callable branch: wrap the callable in a
FailureIsolationMiddleware (now category-gated via catch, proposal 0074) and a failing leg emits a
FailureIsolatedEvent and contributes its degraded update (or nothing). §11.7's Branch-middleware
degrade partial-contribution rule applies unchanged — a callable branch that degrades to a subset of
fields contributes only those. No new per-leg config (the request's "on-leg-error") is introduced;
per-leg degrade is branch middleware, exactly as for subgraph branches.
Observer events and spans¶
A callable branch has no inner nodes, so it is the unit: it emits one started / completed
observer pair on the graph-engine §6 stream, keyed by its branch_name (the branch's key in the
branches mapping) as its event-source identity — paralleling how a subgraph branch's inner nodes carry
branch_name, but with the branch itself as the single emitting unit. The bundled OTel observer renders
it as a branch span under branch_name via the existing §5.7 machinery (no new attribute). A
when-skipped branch emits neither event nor span. Failure-isolation on a callable branch emits its
FailureIsolatedEvent per §11.7 as above.
Conformance test impact¶
New fixtures under spec/pipeline-utilities/conformance/ (the callable-branch call form and the when
predicate are expressed in the fixture branches directive, self-documented in the fixtures' headers,
paralleling the existing subgraph-branch directive):
- 073 — inline-callable branches basic: two
callbranches over shared parent state run concurrently and their returned partial updates merge into disjoint parent fields (no subgraph / projection). - 074 — conditional
whenskip: a parallel-branches node with one branch carryingwhenthat evaluatesfalse; that branch is skipped (contributes nothing, no event), the sibling runs. - 075 — per-leg failure-isolation on a callable branch: a
callbranch wrapped inFailureIsolationMiddlewareraises, degrades to its configured update, and emits aFailureIsolatedEvent; the sibling branch completes normally.
The existing parallel-branches fixtures (032–036) are unchanged.
Alternatives considered¶
- A third primitive (a dedicated lightweight parallel-legs node). Rejected: a "leg" is a degenerate branch (one function over parent state); a third parallel primitive alongside fan-out and parallel-branches is surface proliferation with a blurred boundary. Extending §11 reuses its concurrency, cancellation, isolation, observability, and fan-in wholesale.
- A runtime-determined branch set (compute which and how many heterogeneous branches at runtime). Out of scope: the declared set plus conditional skip covers the motivating need. A fully runtime-invented set is a larger, separable design (per-unit identity / resume / determinism implications) deferred to a future proposal if a workload requires it.
- Express the conditional through graph routing instead of
when. Rejected: the branch set lives inside a single node; routing (conditional edges) operates between nodes and cannot skip one branch within a parallel-branches node. The condition belongs on the branch. - An always-run no-op branch for the conditional case. Rejected: it clutters the trace with a no-op
span and wastes a dispatch;
whenstates the intent directly and produces a clean (absent) trace. - Give callable branches
inputs/outputsprojection too (symmetry with subgraph branches). Rejected: a callable reads parent state and returns parent-shaped updates directly — projection is the subgraph form's concern. Adding it would re-import the ceremony this form exists to drop. - Do nothing — keep the hand-rolled gather. Rejected: the gather forfeits the correct cancellation
discipline (§11.5) and the per-leg
FailureIsolatedEvents (§11.7) the primitive already provides; the weight barrier is the only reason consumers avoid it, and this proposal removes it.
Open questions¶
- All-branches-skipped guard. This proposal makes an all-skipped parallel-branches node a valid no-op. If a workload later wants an "at least one branch must dispatch" assertion, that is a small additive follow-on (a node-level flag), not a v1 concern.