0036: graph-engine — Fan-Out Collection Reducers (concat_flatten, merge_all)¶
- Status: Accepted
- Author: Chris Colinsky
- Created: 2026-05-27
- Accepted: 2026-05-27
- Targets: spec/graph-engine/spec.md (extends §2 Concepts — Reducer entry — required-built-in set with two new members,
concat_flattenandmerge_all); spec/pipeline-utilities/spec.md (broadens §9.3 Per-instance fan-intarget_fieldreducer contract — was list-extending-only; now accepts any §2 built-in compatible with the engine-produced list of per-instance values, explicitly enumeratingappend/concat_flatten/merge_all); spec/graph-engine/conformance/ (two new fixture pairs026-reducer-concat-flatten.{yaml,md}and027-reducer-merge-all.{yaml,md}, slotted after the existing 003 / 004 / 005 reducer fixtures plus the intervening 006-025 non-reducer fixtures) - Related: 0001 (graph-engine foundation — established the required-built-in reducer set; this proposal extends it), 0005 (parallel fan-out — defines the per-instance collection pattern that motivates both reducers)
- Supersedes:
Summary¶
Add two required built-in reducers to graph-engine §2:
concat_flatten (for list-shaped per-instance values collected into
a flat parent list) and merge_all (for dict-shaped per-instance
values folded into a single parent dict). §2 currently mandates
that implementations MUST provide at least last_write_wins,
append, and merge; this proposal expands that set to five.
Both new reducers exist to resolve the structural mismatch
introduced when a fan-out subgraph's per-instance value is itself
collection-shaped. The fan-out engine (pipeline-utilities §9)
collects one value per successful per-instance subgraph, wrapping
each into one element of the parent's target_field list. When the
per-instance value is a list[X] or dict[str, X], the parent
receives list[list[X]] or list[dict] respectively. The existing
append and merge reducers were designed for the single-value
case and cannot consume either nested shape correctly:
appendpreserves the nesting, producinglist[list[X]]at a field declared aslist[X]— Pydantic / equivalent validation fails.mergerequires the update itself to be adict, not alist[dict]— type mismatch at the reducer boundary, fails viaReducerError.
The two new reducers are the strict N-collection analogs:
concat_flatten flattens one level of list nesting; merge_all
folds N dicts into the prior with last-write-wins per key. Both
mirror append / merge's strict shape contract — non-matching
inputs raise ReducerError per §4 rather than auto-detecting and
silently choosing a fallback behavior. Picking the right reducer
for a field is a schema-modeling decision; the strict contract
makes shape mismatches fail at the boundary instead of producing
half-correct state.
No change to §4 error categories (both reducers route failures
through the existing ReducerError / reducer_error machinery),
no change to the fan-out collection contract in pipeline-utilities
§9, no change to existing append / last_write_wins / merge
behavior.
Motivation¶
graph-engine's fan-out facility (pipeline-utilities §9) collects
one value per successful per-instance subgraph: the per-instance
subgraph's collect_field value becomes one element of the
parent's target_field list. This contract is exact for the common
case where each instance emits a single X and the parent collects
list[X].
When each instance's collect_field is itself a collection — a
list[X] or a dict[str, X] — the parent's target_field
receives list[list[X]] or list[dict] respectively. Neither
existing reducer (append, merge) handles this:
The list case (concat_flatten)¶
A state field declared as list[X] (because at the schema level
the field holds flat records) then fails validation: elements are
list[X], not X. Any fan-out boundary where the per-instance
value is list-shaped hits this, and the pattern recurs across
distinct graph capabilities within a single pipeline whenever
multiple fan-out subgraphs each produce per-instance lists for
domain-specific reasons (one-to-many extraction, per-instance
candidate generation, etc.).
The dict case (merge_all)¶
A state field declared as dict[str, X] collecting per-instance
contribution dicts cannot use merge directly: the engine wraps
each per-instance dict as one element of a list, and merge's
contract requires the update itself to be a dict. The fold across
N dicts (last-write-wins per key, consistent with merge's
single-dict semantics) has no built-in.
The dict case is the structural analog of the list case: a
fan-out subgraph whose per-instance state contributes keyed
records (e.g., each instance produces a dict[str, X] of
name → value pairs) produces list[dict] at the parent's
target_field, which merge cannot consume. Both new reducers
complete §2's required-built-in set for the fan-out collection
pattern.
Workarounds and why they fail¶
Three workarounds exist today for either case, each with costs:
- Restructure the subgraph to emit a single value. Works when the per-instance one-to-many shape is incidental; doesn't work when the per-instance multiplicity is essential to the prompt's effectiveness (e.g., the prompt benefits from seeing all candidates an instance produces together rather than running once per candidate, which multiplies LLM call volume and degrades quality).
- Register a custom reducer per field. Solves the problem but each adopter re-derives the same ~15 lines per shape; reducer names and semantics drift across services and across language implementations.
- Post-process in a node downstream of the fan-out. Adds a node whose only purpose is to flatten one level or fold N dicts. Doesn't compose with the state-schema-as-declaration pattern (the nested / list-of-dicts intermediate state must still validate).
Adopters consistently end up at workaround 2: a small custom reducer. Codifying both shapes in §2 as required built-ins:
- Names the operations consistently across all implementations.
- Makes the conformance contract testable (one fixture per reducer covers each success path plus the error contracts).
- Lets the state-schema declaration be self-documenting
(
field: Annotated[list[X], concat_flatten]andfield: Annotated[dict[str, X], merge_all]directly signal "fan-out target collecting list-emitting / dict-emitting subgraphs").
Detailed design¶
§2 reducer set — addition¶
§2's Reducer entry currently reads:
"Implementations MUST provide at least:
last_write_wins,append(for list-typed fields), andmerge(for mapping-typed fields). Users MAY register custom reducers per field."
Updated to:
"Implementations MUST provide at least:
last_write_wins,append(for list-typed fields),merge(for mapping-typed fields),concat_flatten(for list-typed fields whose updates are lists of lists — e.g., fan-out target fields collecting list-emitting per-instance values), andmerge_all(for mapping-typed fields whose updates are lists of mappings — e.g., fan-out target fields collecting dict-emitting per-instance values). Users MAY register custom reducers per field."
Two new paragraphs follow the existing reducer-definition prose, specifying each new reducer's semantics.
concat_flatten semantics¶
"
concat_flattensemantics.concat_flatten(prior, update)returns the concatenation ofpriorwith the one-level flattening ofupdate. BothpriorandupdateMUST be lists, and every element ofupdateMUST itself be a list. Violations raiseReducerErrorper §4 (the engine MUST surface the offending field, the reducer name, and a root-cause naming the non-list value). Emptyupdateis a no-op (returnspriorunchanged). Empty sub-lists insideupdatecontribute zero elements (the one-to-many fan-out case where an instance legitimately produces zero records). Implementations MUST NOT auto-detect whetherupdateis a list of lists vs. a flat list —concat_flattenis strictly the two-level reducer; callers with mixed-shape requirements MUST register a custom reducer rather than rely on shape-dependent behavior."
merge_all semantics¶
"
merge_allsemantics.merge_all(prior, update)folds the sequence of mappings inupdateintoprior, applying the same shallow merge semantics asmerge(later writes win on key conflict; non-conflicting keys frompriorare preserved). Forupdate = [d_1, d_2, ..., d_n], the result is equivalent to applyingmergeN times sequentially:merge(merge(...merge(merge(prior, d_1), d_2)...), d_n), so withinupdatelast-write-wins applies across all N dicts (e.g., ifd_2andd_nboth set keyk,d_n's value wins).priorMUST be a mapping (dict / equivalent),updateMUST be a list, and every element ofupdateMUST itself be a mapping. Violations raiseReducerErrorper §4. Emptyupdateis a no-op (returnspriorunchanged). Empty mappings insideupdatecontribute zero keys. Implementations MUST NOT auto-detect whetherupdateis a list of mappings vs. a single mapping —merge_allis strictly the list-of-mappings reducer; callers needing both behaviors on the same field MUST register a custom reducer rather than rely on shape-dependent behavior."
The strict semantics for both reducers are intentional. They are
the duals of append / merge for the fan-out-collection case,
not supersets. Picking the wrong reducer for a field fails fast
at the boundary instead of producing half-correct state.
Conformance fixtures¶
Two new fixture pairs under spec/graph-engine/conformance/,
slotting after 003-reducer-last-write-wins / 004-reducer-append /
005-reducer-merge.
026-reducer-concat-flatten.{yaml,md} covering:
- Success path. Two nodes write to a
concat_flatten-reduced list field with list-of-list updates; final state shows the concatenated, flattened result. - Empty update path. A node writes
update = []; final state showspriorunchanged. - Empty sub-list path. A node writes
update = [[], []]; the reducer emits no elements but no error. - Non-list-element error path. A node writes
update = [[a], "not a list"]; the engine raisesreducer_error; the error surfaces the failing field and reducer.
027-reducer-merge-all.{yaml,md} covering:
- Success path. Two nodes write to a
merge_all-reduced dict field with list-of-mapping updates; final state shows the cumulative shallow merge, with later writes winning on key conflict both withinupdateand across writes. - Empty update path. A node writes
update = []; final state showspriorunchanged. - Empty mapping path. A node writes
update = [{}, {}]; the reducer adds no keys but no error. - Non-mapping-element error path. A node writes
update = [{k: 1}, "not a mapping"]; the engine raisesreducer_error; the error surfaces the failing field and reducer.
Both fixtures use a permissive field type declaration (type: list
for 026, type: dict for 027 — no element constraint) so the
reducer is the layer enforcing the list-of-collections shape rather
than the typed-state validation layer.
The non-list-update and non-list-prior error contracts
(symmetrically, non-list-update and non-mapping-prior for
merge_all) are spec-normative — the reducer MUST raise on these
inputs per the semantics paragraphs above — but in strict-typed
implementations the typed-state validation layer catches them
BEFORE the reducer, raising a state-validation-style error rather
than reducer_error specifically. The fixture-covered non-element
error case is the one the reducer is GUARANTEED to be the
gatekeeper for, independent of how strict the implementation's
typed-state layer is.
No new harness DSL extensions; the existing
state.fields.<f>.reducer declaration syntax already supports
per-field reducer naming.
Why not auto-flatten / auto-merge in the fan-out engine¶
The fan-out engine's contract (pipeline-utilities §9) is "collect one value per successful per-instance subgraph." Auto-handling collection-shaped values at the engine level would couple the engine's behavior to the per-instance value's runtime shape, which:
- Breaks the legitimate-collection-emit case where the parent does
want
list[list[X]]orlist[dict]preserved (e.g., per-instancelist[ToolCall]collected intolist[list[ToolCall]]so callers can attribute tool calls back to their originating instance). - Hides the schema-modeling decision the user has to make anyway (is this field flat or nested? merged or listed?) behind engine behavior.
- Doesn't compose with direct node writes to the same field —
engine auto-handling works there but plain
node.updatedoesn't, creating asymmetric write paths.
Placing the operations at the reducer level keeps the engine's contract narrow, makes the user's schema declaration the single source of truth for the field shape, and works symmetrically for fan-out collection AND direct node writes.
Why not a flag on FanOutConfig¶
A flatten: bool (or merge_collected: bool) flag on
FanOutConfig introduces:
- An orthogonal axis where reducer-choice and flag-choice both
affect the final shape, with some combinations nonsensical
(
flatten=True+appendwould over-flatten;merge_collected=True+concat_flattenis meaningless). - A two-place declaration: the user must remember to set the flag AND pick the right reducer.
- A site-specific scope: the flag affects this fan-out only, whereas the reducer is a property of the field that any writer (fan-out or direct) honors.
The reducer-only approach keeps the state schema self-documenting and the write paths symmetric.
Spec-text changes¶
Edits to spec/graph-engine/spec.md §2 — Reducer entry:
- The required-built-in sentence expands from three named reducers to five.
- Two new paragraphs immediately after the Reducer entry's existing prose specifying the strict semantics, error contracts, empty-update and empty-element semantics, and the explicit rejection of auto-detect — one paragraph per new reducer.
One cross-spec edit to spec/pipeline-utilities/spec.md §9.3 —
Per-instance fan-in:
- The
target_fieldreducer contract broadens from list-extending-only (appendor user equivalent that concatenates list values) to any reducer compatible with the engine-produced list of per-instance values. Permitted §2 built-ins are enumerated explicitly:append,concat_flatten,merge_all. User-defined reducers MAY still be used, provided theirupdateargument accepts the engine-produced list. Theextra_outputsreducer contract is unchanged.
No changes to graph-engine §3 (Execution model), §4 (Error
categories — the existing ReducerError / reducer_error
machinery covers both new reducers' failures unchanged), §5
(Determinism), §6 (Observer hooks), pipeline-utilities §9.1-§9.2
or §9.4-§9.7 (fan-out engine contract unchanged), or any other
§-section.
Conformance fixtures¶
Two new pairs:
spec/graph-engine/conformance/026-reducer-concat-flatten.{yaml,md}spec/graph-engine/conformance/027-reducer-merge-all.{yaml,md}
Existing reducer fixtures 003 (last-write-wins), 004 (append), 005 (merge) remain unchanged.
Versioning¶
MINOR bump v0.27.0. Adding two new required built-ins expands
the implementations-MUST-provide set, which is an additive
normative change to the conformance surface. Pre-1.0 SemVer permits
MINOR bumps for changes that expand the conformance contract;
implementations that pass the v0.26.x graph-engine fixtures
without concat_flatten and merge_all would no longer pass
v0.27.0 conformance, which is the intended behavior — the new
required built-ins ARE the change.
No breaking changes for caller code: existing reducer declarations
(last_write_wins, append, merge, custom) continue to work
unchanged. The change is purely additive at the
implementation-provides-this surface.
Backwards compatibility¶
- For callers: no breaking change. Existing graphs that use
append,last_write_wins,merge, or custom reducers continue to work unchanged. - For implementations: v0.27.0 conformance requires shipping
both
concat_flattenandmerge_all. Implementations on v0.26.x or earlier MAY ship either ahead of v0.27.0 acceptance with no observable difference (they are new names in a registry that didn't previously include them).
Open questions¶
None at draft time. The design decisions (required-built-in, strict semantics for both reducers, naming, fixture placement, shipping both together rather than sequentially) are answered above. The boundary between each new reducer and a hypothetical auto-detecting variant is explicitly drawn (reject auto-detect for both). The boundary between either reducer and engine-level auto-handling is explicitly drawn (reject engine-level coupling).