Skip to content

Commit b4f54b6

Browse files
Add concat_flatten + merge_all reducers; activate 0035/0036 fixtures (#88)
* Add concat_flatten + merge_all reducers; activate 0035/0036 fixtures Bumps the spec submodule v0.26.1 → v0.27.1 and lands the remaining two proposals of the v0.10.0 batch. Proposal 0036 (fan-out collection reducers): - concat_flatten — the list-of-lists analog of append. A fan-out subgraph emitting list[X] per instance lands list[list[X]] at the parent target_field; this reducer flattens one level onto prior. - merge_all — the list-of-mappings analog of merge. A fan-out subgraph emitting dict[str, X] per instance lands list[dict]; this reducer folds the sequence into prior with shallow last-write-wins per key. Both are strict like their single-level counterparts: they raise TypeError on a bad update-element shape, which the engine wraps as ReducerError per graph-engine §4. Exported from openarmature.graph and registered in the conformance adapter's REDUCERS map. No fan-out config-validation change was needed — OA never restricted target_field to append (it only checks field declaration). Proposal 0035 (Langfuse graph-topology fixtures): - Un-defers 031/032/033 in the Langfuse conformance harness. Spec v0.27.1 patched the two fixture-vs-impl ambiguities raised in the clarify-subgraph-name-semantics coord thread (fixture 031's outer_out step 2 → 3 per the §6 shared counter; fixture 033's detached-trace inner namespace → the wrapper node name). The Option A subgraph_identity wiring already on main satisfies both. - Ports the OTel harness's _patch_unsupported_directives into the Langfuse harness so the topology fixtures' update_pure_from_state inner nodes (computed values the structural assertions don't inspect) run as no-ops. Conformance adapter: parse bare `list` / `dict` field types (the 0036 fixtures use unconstrained container types so the reducer, not the typed-state layer, is the gatekeeper for the list-of-lists / list-of-mappings shape). conformance.toml: spec_pin → v0.27.1, [proposals."0036"] added. All of 0031-0036 stay not-yet until the v0.10.0 release PR flips them to implemented since = 0.10.0 together. Docs: state-and-reducers + fan-out concept pages document the five built-in reducers and the per-instance collection-shape recipe; the AGENTS.md non-obvious-shape note is reframed from a custom- reducer workaround to the now-real built-ins. 12 new reducer unit tests (success + strict-raise paths for both). 937 passed, 130 skipped; lint / format / pyright / manifest clean. * Address PR 88 review: self-contained reducer snippets + accurate shapes - concat_flatten / merge_all doc snippets: add the missing `from typing import Annotated` / `from pydantic import Field` imports so each recipe is self-contained. - Reword the per-instance collection-shape sentence: a mapping per-instance value lands list[dict] (merge_all), not list[list[X]] — name the two non-flat shapes separately instead of lumping the mapping case under "list-of-lists". AGENTS.md regenerated from docs/agent/non-obvious-shapes.md.
1 parent 4ca3d32 commit b4f54b6

14 files changed

Lines changed: 319 additions & 64 deletions

conformance.toml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929

3030
[manifest]
3131
implementation = "openarmature-python"
32-
spec_pin = "v0.26.0"
32+
spec_pin = "v0.27.1"
3333

3434
# Status values:
3535
# implemented — shipped behavior matches the proposal's contract
@@ -150,9 +150,9 @@ status = "textual-only"
150150
since = "0.9.0"
151151
note = "Drain snapshot semantic and timeout-input validation already implemented as part of the proposal 0010 impl PR (v0.9.0); no additional module-level work needed."
152152

153-
# Spec v0.23.0-v0.26.1 batch (proposals 0031, 0032, 0033, 0034, 0035).
154-
# All five have impl work landing across the v0.10.0 release cycle;
155-
# status stays `not-yet` until the release PR flips them to
153+
# Spec v0.23.0-v0.27.1 batch (proposals 0031, 0032, 0033, 0034, 0035,
154+
# 0036). All six have impl work landing across the v0.10.0 release
155+
# cycle; status stays `not-yet` until the release PR flips them to
156156
# `implemented` with `since = "0.10.0"`. The pinned spec submodule
157157
# advances ahead of the impl status because newer fixtures need to be
158158
# visible to the conformance harness as each PR lands.
@@ -170,3 +170,6 @@ status = "not-yet"
170170

171171
[proposals."0035"]
172172
status = "not-yet"
173+
174+
[proposals."0036"]
175+
status = "not-yet"

docs/agent/non-obvious-shapes.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -196,24 +196,35 @@ attributed_candidates.0 Input should be a valid dictionary or
196196
input_type=list]
197197
```
198198

199-
The right fix is a flattening reducer. Until OA ships the spec-blessed built-ins (proposal 0036 — `concat_flatten` for the list-of-lists case, `merge_all` for the dict-of-mappings case — accepted in spec v0.27.0 but not yet absorbed into the python impl), use a small custom reducer:
199+
The fix is the `concat_flatten` built-in reducer (proposal 0036) — the list-of-lists analog of `append`. Declare it on the parent's collection field:
200200

201201
```python
202-
from openarmature.graph import Reducer
202+
from typing import Annotated
203+
204+
from pydantic import Field
205+
206+
from openarmature.graph import State, concat_flatten
207+
208+
class PipelineState(State):
209+
attributed_candidates: Annotated[list[ClaimCandidate], concat_flatten] = Field(default_factory=list)
210+
```
211+
212+
`concat_flatten` folds the per-instance lists into one flat list (`[*prior, *(item for sublist in update for item in sublist)]`), strict like `append` — it raises `ReducerError` if any element of the update isn't itself a list.
213+
214+
The dict-shaped analog is `merge_all` (also proposal 0036): when each fan-out instance contributes a `dict[str, X]`, the parent's `target_field` receives `list[dict]`, which plain `merge` can't consume. `merge_all` folds the sequence of mappings into the prior with shallow last-write-wins per key:
203215

204-
class _ConcatFlatten(Reducer):
205-
name = "concat_flatten"
216+
```python
217+
from typing import Annotated
206218

207-
def __call__(self, prior: list[Any], update: list[list[Any]]) -> list[Any]:
208-
return [*prior, *(item for sublist in update for item in sublist)]
219+
from pydantic import Field
209220

210-
concat_flatten = _ConcatFlatten()
221+
from openarmature.graph import State, merge_all
211222

212223
class PipelineState(State):
213-
attributed_candidates: Annotated[list[ClaimCandidate], concat_flatten] = ...
224+
keyed_results: Annotated[dict[str, Result], merge_all] = Field(default_factory=dict)
214225
```
215226

216-
Single-record-per-instance fan-outs (`collect_field: str`, parent field `Annotated[list[X], append]`) don't hit this — the engine still wraps each instance's value as one element, but `append` flattens it correctly since each element is already an `X`. The list-of-lists shape only emerges when the per-instance value is itself a list.
227+
Single-record-per-instance fan-outs (`collect_field: str`, parent field `Annotated[list[X], append]`) don't hit this — the engine still wraps each instance's value as one element, but `append` flattens it correctly since each element is already an `X`. The two non-flat shapes emerge only when the per-instance value is itself a container: a `list[X]` per instance lands `list[list[X]]` (use `concat_flatten`), and a `dict[str, X]` per instance lands `list[dict]` (use `merge_all`).
217228

218229
If a parent field is populated by BOTH direct node writes AND fan-out collection, that's an architectural ambiguity worth fixing upstream — split into two fields, or pick one path.
219230

docs/concepts/fan-out.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,29 @@ containing:
100100
- `on_empty="noop"` for an empty items_field → all the above with empty
101101
lists; `count_field` set to 0.
102102

103+
### Choosing the `target_field` reducer
104+
105+
The engine writes `target_field` as a list with one entry per
106+
successful instance: `[instance_0_value, instance_1_value, …]`. The
107+
reducer you declare on the parent field decides how that list folds
108+
into prior state:
109+
110+
- Each instance emits a single value (`collect_field: X`) →
111+
declare `append` on `Annotated[list[X], append]`. Each instance's
112+
value is already an `X`; `append` concatenates cleanly.
113+
- Each instance emits a `list[X]` (0..N records per instance) → the
114+
engine lands `list[list[X]]`. Declare `concat_flatten` instead —
115+
it flattens one level so the parent field stays `list[X]`. Plain
116+
`append` would leave the nesting and fail Pydantic validation.
117+
- Each instance emits a `dict[str, X]` → the engine lands
118+
`list[dict]`. Declare `merge_all`, which folds the mappings into
119+
the parent dict with last-write-wins per key. Plain `merge` can't
120+
consume a `list[dict]`.
121+
122+
`concat_flatten` and `merge_all` are strict — they raise
123+
`ReducerError` if an update element isn't the expected list/mapping
124+
shape. See [state and reducers](state-and-reducers.md#five-built-in-reducers).
125+
103126
## Empty fan-outs
104127

105128
If `items_field` is set and the parent list is empty (or `count`

docs/concepts/state-and-reducers.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,18 +107,30 @@ engine applies the merge consistently. If two nodes write the same
107107
field and the merge strategy is wrong, the fix is one line on the
108108
schema, not surgery across call sites.
109109

110-
## Three built-in reducers
110+
## Five built-in reducers
111111

112-
| Reducer | Semantics | Typical use |
113-
| ----------------- | ---------------------------------------- | ------------------------------------- |
114-
| `last_write_wins` | `partial` replaces `prior` *(default)* | Scalars owned by a single node |
115-
| `append` | `[*prior, *partial]` for list fields | Traces, message history, accumulators |
116-
| `merge` | `{**prior, **partial}` (shallow) | Metadata bags, namespaced state |
112+
| Reducer | Semantics | Typical use |
113+
| ----------------- | ----------------------------------------------- | ------------------------------------- |
114+
| `last_write_wins` | `partial` replaces `prior` *(default)* | Scalars owned by a single node |
115+
| `append` | `[*prior, *partial]` for list fields | Traces, message history, accumulators |
116+
| `merge` | `{**prior, **partial}` (shallow) | Metadata bags, namespaced state |
117+
| `concat_flatten` | `[*prior, *(x for sub in partial for x in sub)]` | Fan-out collecting `list[X]` per instance |
118+
| `merge_all` | fold `list[dict]` into `prior` (last-write-wins) | Fan-out collecting `dict[str, X]` per instance |
117119

118120
```python
119-
from openarmature.graph import append, last_write_wins, merge
121+
from openarmature.graph import append, concat_flatten, last_write_wins, merge, merge_all
120122
```
121123

124+
`concat_flatten` and `merge_all` exist for the fan-out collection
125+
shapes: when a fan-out subgraph emits `list[X]` per instance, the
126+
parent's `target_field` receives `list[list[X]]` (which `append`
127+
would leave nested); when it emits `dict[str, X]`, the parent
128+
receives `list[dict]` (which `merge` can't consume). Both are
129+
strict like their single-level counterparts — they raise
130+
`ReducerError` when an update element isn't the expected
131+
list/mapping shape. See the [fan-out](fan-out.md) page for the
132+
full pattern.
133+
122134
You can write your own. A reducer is any named callable matching the
123135
`(prior, partial) -> new` contract.
124136

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Specification = "https://github.com/LunarCommand/openarmature-spec"
5858
openarmature = "openarmature.cli:main"
5959

6060
[tool.openarmature]
61-
spec_version = "0.26.1"
61+
spec_version = "0.27.1"
6262

6363
[dependency-groups]
6464
dev = [

src/openarmature/AGENTS.md

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# OpenArmature — Agent documentation
22

3-
*This is the agent guide bundled with the openarmature Python package, version 0.9.0 (spec v0.26.1). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.*
3+
*This is the agent guide bundled with the openarmature Python package, version 0.9.0 (spec v0.27.1). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.*
44

55
## TL;DR
66

@@ -10,7 +10,7 @@ OpenArmature is a workflow framework for LLM pipelines and tool-calling agents
1010

1111
## Capability contracts
1212

13-
_Sourced from openarmature-spec v0.26.1. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md`. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
13+
_Sourced from openarmature-spec v0.27.1. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md`. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
1414

1515
### Capability: `graph-engine`
1616

@@ -46,8 +46,31 @@ engine constant, not a reserved node name, so a user node may happen to be named
4646

4747
**Reducer.** A function that merges a node's partial update into the prior state for a given field. Each state
4848
field has exactly one reducer. The default reducer is _last-write-wins_ (the new value replaces the old).
49-
Implementations MUST provide at least: `last_write_wins`, `append` (for list-typed fields), and `merge`
50-
(for mapping-typed fields). Users MAY register custom reducers per field.
49+
Implementations MUST provide at least: `last_write_wins`, `append` (for list-typed fields), `merge`
50+
(for mapping-typed fields), `concat_flatten` (for list-typed fields whose updates are lists of lists —
51+
e.g., fan-out target fields collecting list-emitting per-instance values), and `merge_all` (for
52+
mapping-typed fields whose updates are lists of mappings — e.g., fan-out target fields collecting
53+
dict-emitting per-instance values). Users MAY register custom reducers per field.
54+
55+
**`concat_flatten` semantics.** `concat_flatten(prior, update)` returns the concatenation of `prior` with the
56+
one-level flattening of `update`. Both `prior` and `update` MUST be lists, and every element of `update` MUST
57+
itself be a list. Violations raise `ReducerError` per §4 (the engine MUST surface the offending field, the
58+
reducer name, and a root-cause naming the non-list value). Empty `update` is a no-op (returns `prior`
59+
unchanged). Empty sub-lists inside `update` contribute zero elements (the one-to-many fan-out case where an
60+
instance legitimately produces zero records). Implementations MUST NOT auto-detect whether `update` is a list
61+
of lists vs. a flat list — `concat_flatten` is strictly the two-level reducer; callers with mixed-shape
62+
requirements MUST register a custom reducer rather than rely on shape-dependent behavior.
63+
64+
**`merge_all` semantics.** `merge_all(prior, update)` folds the sequence of mappings in `update` into `prior`,
65+
applying the same shallow merge semantics as `merge` (later writes win on key conflict; non-conflicting keys
66+
from `prior` are preserved). For `update = [d_1, d_2, ..., d_n]`, the result is equivalent to applying `merge`
67+
N times sequentially: `merge(merge(...merge(merge(prior, d_1), d_2)...), d_n)`, so within `update`
68+
last-write-wins applies across all N dicts (e.g., if `d_2` and `d_n` both set key `k`, `d_n`'s value wins).
69+
`prior` MUST be a mapping, `update` MUST be a list, and every element of `update` MUST itself be a mapping.
70+
Violations raise `ReducerError` per §4. Empty `update` is a no-op (returns `prior` unchanged). Empty mappings
71+
inside `update` contribute zero keys. Implementations MUST NOT auto-detect whether `update` is a list of
72+
mappings vs. a single mapping — `merge_all` is strictly the list-of-mappings reducer; callers needing both
73+
behaviors on the same field MUST register a custom reducer rather than rely on shape-dependent behavior.
5174

5275
**Subgraph.** A compiled graph used as a node inside another graph. A subgraph executes against its own state
5376
schema and produces a partial update that is merged into the parent's state. The merge uses the same reducer
@@ -1035,24 +1058,35 @@ attributed_candidates.0 Input should be a valid dictionary or
10351058
input_type=list]
10361059
```
10371060

1038-
The right fix is a flattening reducer. Until OA ships the spec-blessed built-ins (proposal 0036 — `concat_flatten` for the list-of-lists case, `merge_all` for the dict-of-mappings case — accepted in spec v0.27.0 but not yet absorbed into the python impl), use a small custom reducer:
1061+
The fix is the `concat_flatten` built-in reducer (proposal 0036) — the list-of-lists analog of `append`. Declare it on the parent's collection field:
10391062

10401063
```python
1041-
from openarmature.graph import Reducer
1064+
from typing import Annotated
1065+
1066+
from pydantic import Field
1067+
1068+
from openarmature.graph import State, concat_flatten
1069+
1070+
class PipelineState(State):
1071+
attributed_candidates: Annotated[list[ClaimCandidate], concat_flatten] = Field(default_factory=list)
1072+
```
1073+
1074+
`concat_flatten` folds the per-instance lists into one flat list (`[*prior, *(item for sublist in update for item in sublist)]`), strict like `append` — it raises `ReducerError` if any element of the update isn't itself a list.
1075+
1076+
The dict-shaped analog is `merge_all` (also proposal 0036): when each fan-out instance contributes a `dict[str, X]`, the parent's `target_field` receives `list[dict]`, which plain `merge` can't consume. `merge_all` folds the sequence of mappings into the prior with shallow last-write-wins per key:
10421077

1043-
class _ConcatFlatten(Reducer):
1044-
name = "concat_flatten"
1078+
```python
1079+
from typing import Annotated
10451080

1046-
def __call__(self, prior: list[Any], update: list[list[Any]]) -> list[Any]:
1047-
return [*prior, *(item for sublist in update for item in sublist)]
1081+
from pydantic import Field
10481082

1049-
concat_flatten = _ConcatFlatten()
1083+
from openarmature.graph import State, merge_all
10501084

10511085
class PipelineState(State):
1052-
attributed_candidates: Annotated[list[ClaimCandidate], concat_flatten] = ...
1086+
keyed_results: Annotated[dict[str, Result], merge_all] = Field(default_factory=dict)
10531087
```
10541088

1055-
Single-record-per-instance fan-outs (`collect_field: str`, parent field `Annotated[list[X], append]`) don't hit this — the engine still wraps each instance's value as one element, but `append` flattens it correctly since each element is already an `X`. The list-of-lists shape only emerges when the per-instance value is itself a list.
1089+
Single-record-per-instance fan-outs (`collect_field: str`, parent field `Annotated[list[X], append]`) don't hit this — the engine still wraps each instance's value as one element, but `append` flattens it correctly since each element is already an `X`. The two non-flat shapes emerge only when the per-instance value is itself a container: a `list[X]` per instance lands `list[list[X]]` (use `concat_flatten`), and a `dict[str, X]` per instance lands `list[dict]` (use `merge_all`).
10561090

10571091
If a parent field is populated by BOTH direct node writes AND fan-out collection, that's an architectural ambiguity worth fixing upstream — split into two fields, or pick one path.
10581092

src/openarmature/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@
2525
"""
2626

2727
__version__ = "0.9.0"
28-
__spec_version__ = "0.26.1"
28+
__spec_version__ = "0.27.1"

src/openarmature/graph/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
from .observer import DrainSummary, Observer, RemoveHandle, SubscribedObserver
5252
from .parallel_branches import BranchSpec, ParallelBranchesNode
5353
from .projection import ExplicitMapping, FieldNameMatching, ProjectionStrategy
54-
from .reducers import Reducer, append, last_write_wins, merge
54+
from .reducers import Reducer, append, concat_flatten, last_write_wins, merge, merge_all
5555
from .state import State
5656
from .subgraph import SubgraphNode
5757

@@ -106,9 +106,11 @@
106106
"TimingRecord",
107107
"UnreachableNode",
108108
"append",
109+
"concat_flatten",
109110
"default_classifier",
110111
"deterministic_backoff",
111112
"exponential_jitter_backoff",
112113
"last_write_wins",
113114
"merge",
115+
"merge_all",
114116
]

0 commit comments

Comments
 (0)