You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
* 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.
Copy file name to clipboardExpand all lines: conformance.toml
+7-4Lines changed: 7 additions & 4 deletions
Original file line number
Diff line number
Diff line change
@@ -29,7 +29,7 @@
29
29
30
30
[manifest]
31
31
implementation = "openarmature-python"
32
-
spec_pin = "v0.26.0"
32
+
spec_pin = "v0.27.1"
33
33
34
34
# Status values:
35
35
# implemented — shipped behavior matches the proposal's contract
@@ -150,9 +150,9 @@ status = "textual-only"
150
150
since = "0.9.0"
151
151
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."
Copy file name to clipboardExpand all lines: docs/agent/non-obvious-shapes.md
+20-9Lines changed: 20 additions & 9 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -196,24 +196,35 @@ attributed_candidates.0 Input should be a valid dictionary or
196
196
input_type=list]
197
197
```
198
198
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:
200
200
201
201
```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
`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:
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`).
217
228
218
229
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.
Copy file name to clipboardExpand all lines: src/openarmature/AGENTS.md
+47-13Lines changed: 47 additions & 13 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,6 +1,6 @@
1
1
# OpenArmature — Agent documentation
2
2
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`.*
4
4
5
5
## TL;DR
6
6
@@ -10,7 +10,7 @@ OpenArmature is a workflow framework for LLM pipelines and tool-calling agents
10
10
11
11
## Capability contracts
12
12
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._
14
14
15
15
### Capability: `graph-engine`
16
16
@@ -46,8 +46,31 @@ engine constant, not a reserved node name, so a user node may happen to be named
46
46
47
47
**Reducer.** A function that merges a node's partial update into the prior state for a given field. Each state
48
48
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 —
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.
51
74
52
75
**Subgraph.** A compiled graph used as a node inside another graph. A subgraph executes against its own state
53
76
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
1035
1058
input_type=list]
1036
1059
```
1037
1060
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:
1039
1062
1040
1063
```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
`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:
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`).
1056
1090
1057
1091
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.
0 commit comments