diff --git a/conformance.toml b/conformance.toml index 5f73488..e0c6b2e 100644 --- a/conformance.toml +++ b/conformance.toml @@ -29,7 +29,7 @@ [manifest] implementation = "openarmature-python" -spec_pin = "v0.26.0" +spec_pin = "v0.27.1" # Status values: # implemented — shipped behavior matches the proposal's contract @@ -150,9 +150,9 @@ status = "textual-only" since = "0.9.0" 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." -# Spec v0.23.0-v0.26.1 batch (proposals 0031, 0032, 0033, 0034, 0035). -# All five have impl work landing across the v0.10.0 release cycle; -# status stays `not-yet` until the release PR flips them to +# Spec v0.23.0-v0.27.1 batch (proposals 0031, 0032, 0033, 0034, 0035, +# 0036). All six have impl work landing across the v0.10.0 release +# cycle; status stays `not-yet` until the release PR flips them to # `implemented` with `since = "0.10.0"`. The pinned spec submodule # advances ahead of the impl status because newer fixtures need to be # visible to the conformance harness as each PR lands. @@ -170,3 +170,6 @@ status = "not-yet" [proposals."0035"] status = "not-yet" + +[proposals."0036"] +status = "not-yet" diff --git a/docs/agent/non-obvious-shapes.md b/docs/agent/non-obvious-shapes.md index 6d220e1..d8508e8 100644 --- a/docs/agent/non-obvious-shapes.md +++ b/docs/agent/non-obvious-shapes.md @@ -196,24 +196,35 @@ attributed_candidates.0 Input should be a valid dictionary or input_type=list] ``` -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: +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: ```python -from openarmature.graph import Reducer +from typing import Annotated + +from pydantic import Field + +from openarmature.graph import State, concat_flatten + +class PipelineState(State): + attributed_candidates: Annotated[list[ClaimCandidate], concat_flatten] = Field(default_factory=list) +``` + +`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. + +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: -class _ConcatFlatten(Reducer): - name = "concat_flatten" +```python +from typing import Annotated - def __call__(self, prior: list[Any], update: list[list[Any]]) -> list[Any]: - return [*prior, *(item for sublist in update for item in sublist)] +from pydantic import Field -concat_flatten = _ConcatFlatten() +from openarmature.graph import State, merge_all class PipelineState(State): - attributed_candidates: Annotated[list[ClaimCandidate], concat_flatten] = ... + keyed_results: Annotated[dict[str, Result], merge_all] = Field(default_factory=dict) ``` -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. +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`). 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. diff --git a/docs/concepts/fan-out.md b/docs/concepts/fan-out.md index be0d58f..b445e30 100644 --- a/docs/concepts/fan-out.md +++ b/docs/concepts/fan-out.md @@ -100,6 +100,29 @@ containing: - `on_empty="noop"` for an empty items_field → all the above with empty lists; `count_field` set to 0. +### Choosing the `target_field` reducer + +The engine writes `target_field` as a list with one entry per +successful instance: `[instance_0_value, instance_1_value, …]`. The +reducer you declare on the parent field decides how that list folds +into prior state: + +- Each instance emits a single value (`collect_field: X`) → + declare `append` on `Annotated[list[X], append]`. Each instance's + value is already an `X`; `append` concatenates cleanly. +- Each instance emits a `list[X]` (0..N records per instance) → the + engine lands `list[list[X]]`. Declare `concat_flatten` instead — + it flattens one level so the parent field stays `list[X]`. Plain + `append` would leave the nesting and fail Pydantic validation. +- Each instance emits a `dict[str, X]` → the engine lands + `list[dict]`. Declare `merge_all`, which folds the mappings into + the parent dict with last-write-wins per key. Plain `merge` can't + consume a `list[dict]`. + +`concat_flatten` and `merge_all` are strict — they raise +`ReducerError` if an update element isn't the expected list/mapping +shape. See [state and reducers](state-and-reducers.md#five-built-in-reducers). + ## Empty fan-outs If `items_field` is set and the parent list is empty (or `count` diff --git a/docs/concepts/state-and-reducers.md b/docs/concepts/state-and-reducers.md index 3a98767..20b6dac 100644 --- a/docs/concepts/state-and-reducers.md +++ b/docs/concepts/state-and-reducers.md @@ -107,18 +107,30 @@ engine applies the merge consistently. If two nodes write the same field and the merge strategy is wrong, the fix is one line on the schema, not surgery across call sites. -## Three built-in reducers +## Five built-in reducers -| Reducer | Semantics | Typical use | -| ----------------- | ---------------------------------------- | ------------------------------------- | -| `last_write_wins` | `partial` replaces `prior` *(default)* | Scalars owned by a single node | -| `append` | `[*prior, *partial]` for list fields | Traces, message history, accumulators | -| `merge` | `{**prior, **partial}` (shallow) | Metadata bags, namespaced state | +| Reducer | Semantics | Typical use | +| ----------------- | ----------------------------------------------- | ------------------------------------- | +| `last_write_wins` | `partial` replaces `prior` *(default)* | Scalars owned by a single node | +| `append` | `[*prior, *partial]` for list fields | Traces, message history, accumulators | +| `merge` | `{**prior, **partial}` (shallow) | Metadata bags, namespaced state | +| `concat_flatten` | `[*prior, *(x for sub in partial for x in sub)]` | Fan-out collecting `list[X]` per instance | +| `merge_all` | fold `list[dict]` into `prior` (last-write-wins) | Fan-out collecting `dict[str, X]` per instance | ```python -from openarmature.graph import append, last_write_wins, merge +from openarmature.graph import append, concat_flatten, last_write_wins, merge, merge_all ``` +`concat_flatten` and `merge_all` exist for the fan-out collection +shapes: when a fan-out subgraph emits `list[X]` per instance, the +parent's `target_field` receives `list[list[X]]` (which `append` +would leave nested); when it emits `dict[str, X]`, the parent +receives `list[dict]` (which `merge` can't consume). Both are +strict like their single-level counterparts — they raise +`ReducerError` when an update element isn't the expected +list/mapping shape. See the [fan-out](fan-out.md) page for the +full pattern. + You can write your own. A reducer is any named callable matching the `(prior, partial) -> new` contract. diff --git a/openarmature-spec b/openarmature-spec index da640df..e61fb08 160000 --- a/openarmature-spec +++ b/openarmature-spec @@ -1 +1 @@ -Subproject commit da640dffa34a8e6c790e97a7f01ebaea2ce723f7 +Subproject commit e61fb0846ca83c6eaa2391d457c89adc9d588670 diff --git a/pyproject.toml b/pyproject.toml index fe9737b..7fb4a87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ Specification = "https://github.com/LunarCommand/openarmature-spec" openarmature = "openarmature.cli:main" [tool.openarmature] -spec_version = "0.26.1" +spec_version = "0.27.1" [dependency-groups] dev = [ diff --git a/src/openarmature/AGENTS.md b/src/openarmature/AGENTS.md index 20a8f17..8d70cea 100644 --- a/src/openarmature/AGENTS.md +++ b/src/openarmature/AGENTS.md @@ -1,6 +1,6 @@ # OpenArmature — Agent documentation -*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`.* +*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`.* ## TL;DR @@ -10,7 +10,7 @@ OpenArmature is a workflow framework for LLM pipelines and tool-calling agents ## Capability contracts -_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._ +_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._ ### Capability: `graph-engine` @@ -46,8 +46,31 @@ engine constant, not a reserved node name, so a user node may happen to be named **Reducer.** A function that merges a node's partial update into the prior state for a given field. Each state field has exactly one reducer. The default reducer is _last-write-wins_ (the new value replaces the old). -Implementations MUST provide at least: `last_write_wins`, `append` (for list-typed fields), and `merge` -(for mapping-typed fields). Users MAY register custom reducers per field. +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), and `merge_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. + +**`concat_flatten` semantics.** `concat_flatten(prior, update)` returns the concatenation of `prior` with the +one-level flattening of `update`. Both `prior` and `update` MUST be lists, and every element of `update` MUST +itself be a list. Violations raise `ReducerError` per §4 (the engine MUST surface the offending field, the +reducer name, and a root-cause naming the non-list value). Empty `update` is a no-op (returns `prior` +unchanged). Empty sub-lists inside `update` contribute zero elements (the one-to-many fan-out case where an +instance legitimately produces zero records). Implementations MUST NOT auto-detect whether `update` is a list +of lists vs. a flat list — `concat_flatten` is 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_all(prior, update)` folds the sequence of mappings in `update` into `prior`, +applying the same shallow merge semantics as `merge` (later writes win on key conflict; non-conflicting keys +from `prior` are preserved). For `update = [d_1, d_2, ..., d_n]`, the result is equivalent to applying `merge` +N times sequentially: `merge(merge(...merge(merge(prior, d_1), d_2)...), d_n)`, so within `update` +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). +`prior` MUST be a mapping, `update` MUST be a list, and every element of `update` MUST itself be a mapping. +Violations raise `ReducerError` per §4. Empty `update` is a no-op (returns `prior` unchanged). Empty mappings +inside `update` contribute zero keys. Implementations MUST NOT auto-detect whether `update` is a list of +mappings vs. a single mapping — `merge_all` is 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. **Subgraph.** A compiled graph used as a node inside another graph. A subgraph executes against its own state 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 input_type=list] ``` -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: +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: ```python -from openarmature.graph import Reducer +from typing import Annotated + +from pydantic import Field + +from openarmature.graph import State, concat_flatten + +class PipelineState(State): + attributed_candidates: Annotated[list[ClaimCandidate], concat_flatten] = Field(default_factory=list) +``` + +`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. + +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: -class _ConcatFlatten(Reducer): - name = "concat_flatten" +```python +from typing import Annotated - def __call__(self, prior: list[Any], update: list[list[Any]]) -> list[Any]: - return [*prior, *(item for sublist in update for item in sublist)] +from pydantic import Field -concat_flatten = _ConcatFlatten() +from openarmature.graph import State, merge_all class PipelineState(State): - attributed_candidates: Annotated[list[ClaimCandidate], concat_flatten] = ... + keyed_results: Annotated[dict[str, Result], merge_all] = Field(default_factory=dict) ``` -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. +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`). 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. diff --git a/src/openarmature/__init__.py b/src/openarmature/__init__.py index 210af66..13cb2e1 100644 --- a/src/openarmature/__init__.py +++ b/src/openarmature/__init__.py @@ -25,4 +25,4 @@ """ __version__ = "0.9.0" -__spec_version__ = "0.26.1" +__spec_version__ = "0.27.1" diff --git a/src/openarmature/graph/__init__.py b/src/openarmature/graph/__init__.py index 9ee3e19..4974259 100644 --- a/src/openarmature/graph/__init__.py +++ b/src/openarmature/graph/__init__.py @@ -51,7 +51,7 @@ from .observer import DrainSummary, Observer, RemoveHandle, SubscribedObserver from .parallel_branches import BranchSpec, ParallelBranchesNode from .projection import ExplicitMapping, FieldNameMatching, ProjectionStrategy -from .reducers import Reducer, append, last_write_wins, merge +from .reducers import Reducer, append, concat_flatten, last_write_wins, merge, merge_all from .state import State from .subgraph import SubgraphNode @@ -106,9 +106,11 @@ "TimingRecord", "UnreachableNode", "append", + "concat_flatten", "default_classifier", "deterministic_backoff", "exponential_jitter_backoff", "last_write_wins", "merge", + "merge_all", ] diff --git a/src/openarmature/graph/reducers.py b/src/openarmature/graph/reducers.py index 80f8550..23cd305 100644 --- a/src/openarmature/graph/reducers.py +++ b/src/openarmature/graph/reducers.py @@ -1,16 +1,22 @@ -# Spec: realizes graph-engine §2 (Reducer concept) — last_write_wins, -# append, and merge are the three built-ins the spec requires. +# Spec: realizes graph-engine §2 (Reducer concept). Proposal 0036 +# (spec v0.27.0) expanded the required-built-in set from three to +# five — adding concat_flatten and merge_all for the fan-out +# collection shapes (a fan-out subgraph emitting list[X] per +# instance lands list[list[X]] at the parent target_field; +# emitting dict[str, X] lands list[dict] — neither of which append +# or merge can consume). """Reducers for merging node updates into state. Each state field has exactly one reducer; the default is -``last_write_wins``. The three built-ins are ``last_write_wins``, -``append`` (for list-typed fields), and ``merge`` (for mapping-typed -fields). +``last_write_wins``. The five built-ins are ``last_write_wins``, +``append`` (for list-typed fields), ``merge`` (for mapping-typed +fields), ``concat_flatten`` (for list-of-lists → flat list), and +``merge_all`` (for list-of-mappings → folded mapping). """ from collections.abc import Mapping -from typing import Any +from typing import Any, cast class Reducer: @@ -56,6 +62,61 @@ def __call__(self, prior: Any, update: Any) -> dict[Any, Any]: return {**prior, **update} +class _ConcatFlatten(Reducer): + # Proposal 0036: the list-of-lists analog of ``append``. The + # fan-out engine lands ``list[list[X]]`` at the parent + # target_field when each instance's collect_field is itself a + # ``list[X]``; this reducer flattens one level onto the prior. + # Strict like ``append`` — every element of ``update`` MUST be a + # list. The TypeError surfaces as a ``ReducerError`` (graph-engine + # §4) once the engine wraps it. + name = "concat_flatten" + + def __call__(self, prior: Any, update: Any) -> list[Any]: + if not isinstance(prior, list): + raise TypeError(f"concat_flatten reducer requires a list prior; got {type(prior).__name__}") + if not isinstance(update, list): + raise TypeError(f"concat_flatten reducer requires a list update; got {type(update).__name__}") + update_list = cast("list[Any]", update) + for i, element in enumerate(update_list): + if not isinstance(element, list): + raise TypeError( + f"concat_flatten reducer requires every update element to be a list; " + f"update[{i}] is {type(element).__name__}" + ) + prior_list = cast("list[Any]", prior) + return [*prior_list, *(item for sublist in update_list for item in cast("list[Any]", sublist))] + + +class _MergeAll(Reducer): + # Proposal 0036: the list-of-mappings analog of ``merge``. The + # fan-out engine lands ``list[dict]`` at the parent target_field + # when each instance's collect_field is a ``dict[str, X]``; this + # reducer folds the sequence into the prior with shallow + # last-write-wins per key (equivalent to applying ``merge`` N + # times sequentially). Strict like ``merge`` — every element of + # ``update`` MUST be a mapping. + name = "merge_all" + + def __call__(self, prior: Any, update: Any) -> dict[Any, Any]: + if not isinstance(prior, Mapping): + raise TypeError(f"merge_all reducer requires a mapping prior; got {type(prior).__name__}") + if not isinstance(update, list): + raise TypeError(f"merge_all reducer requires a list update; got {type(update).__name__}") + update_list = cast("list[Any]", update) + result: dict[Any, Any] = dict(cast("Mapping[Any, Any]", prior)) + for i, element in enumerate(update_list): + if not isinstance(element, Mapping): + raise TypeError( + f"merge_all reducer requires every update element to be a mapping; " + f"update[{i}] is {type(element).__name__}" + ) + result.update(cast("Mapping[Any, Any]", element)) + return result + + last_write_wins: Reducer = _LastWriteWins() append: Reducer = _Append() merge: Reducer = _Merge() +concat_flatten: Reducer = _ConcatFlatten() +merge_all: Reducer = _MergeAll() diff --git a/tests/conformance/adapter.py b/tests/conformance/adapter.py index de3c92d..d12c18a 100644 --- a/tests/conformance/adapter.py +++ b/tests/conformance/adapter.py @@ -33,8 +33,10 @@ State, SubgraphNode, append, + concat_flatten, last_write_wins, merge, + merge_all, ) from openarmature.graph.events import NodeEvent from openarmature.graph.observer import Observer @@ -46,6 +48,8 @@ "last_write_wins": last_write_wins, "append": append, "merge": merge, + "concat_flatten": concat_flatten, + "merge_all": merge_all, } @@ -62,9 +66,14 @@ def _parse_type(s: str) -> Any: # Unparameterized container types — parallel-branches fixtures # 034/035/037 use ``dict`` and ``list`` as state-field types # for accumulator slots (branch_errors, merged_dict, collected_labels) - # where the element shape is heterogeneous across branches. + # where the element shape is heterogeneous across branches. The + # proposal-0036 reducer fixtures (026/027) use bare ``list`` / + # ``dict`` deliberately so the reducer (not the typed-state layer) + # is the gatekeeper for the list-of-lists / list-of-mappings shape. if s == "dict": return dict[str, Any] + if s == "list": + return list[Any] if s == "list": return list[dict[str, Any]] # proposal-0009 fixture 052: ``error_entry`` is the spec's shorthand diff --git a/tests/conformance/test_observability_langfuse.py b/tests/conformance/test_observability_langfuse.py index dfac35e..1f4506f 100644 --- a/tests/conformance/test_observability_langfuse.py +++ b/tests/conformance/test_observability_langfuse.py @@ -57,24 +57,17 @@ # into ``trace.metadata`` + every ``observation.metadata`` # per §8.4.1 + §8.4.2). "027-langfuse-caller-supplied-metadata", - # 031 / 032 / 033 — proposal 0035 (spec v0.26.1). The - # subgraph_identity wiring (per coord thread - # `clarify-subgraph-name-semantics` msg 02 — Option A) is - # landed: SubgraphNode.subgraph_identity / FanOutConfig. - # subgraph_identity flow through NodeEvent.subgraph_identities - # to observer-side metadata.subgraph_name emission. Two - # remaining spec/fixture ambiguities block fixture activation: - # (1) ``step`` semantics on the wrapper synth observation vs. - # ``outer_out``: fixture 031 expects ``outer_out`` at step 2 - # but graph-engine §6 says "subgraph-internal node executions - # increment the same counter" so the python engine emits - # step 3 (outer_in=0, inner_x=1, inner_y=2, outer_out=3). - # (2) ``namespace`` rewrite for observations inside a - # detached trace: fixture 033 case 1 expects - # ``namespace: ["long_running_workflow", "step"]`` (using - # subgraph identity for the wrapper component) but the - # engine's event carries the wrapper node name (``"dispatch"``). - # Both queued for spec input via a follow-up coord thread. + # 031 / 032 / 033 — proposal 0035. Activated against spec + # v0.27.1, which patched the two fixture-vs-impl ambiguities + # raised in coord thread `clarify-subgraph-name-semantics` + # (msg 04): fixture 031's `outer_out` step corrected 2 → 3 + # (graph-engine §6 shared-counter), and fixture 033's + # detached-trace inner namespace corrected to the wrapper + # node name (`["dispatch", "step"]`). The Option A + # subgraph_identity wiring on main satisfies both. + "031-langfuse-subgraph-span-hierarchy", + "032-langfuse-fan-out-per-instance-spans", + "033-langfuse-detached-trace-mode", } ) @@ -187,6 +180,33 @@ def _has_topology_constructs(case: Mapping[str, Any]) -> bool: return False +def _patch_unsupported_directives(spec: Mapping[str, Any]) -> None: + """Replace inner-node test-seam directives the cross-capability + adapter doesn't translate (``update_pure_from_state``) with a + benign ``update_pure: {}`` no-op. The topology fixtures assert + observation structure (parenting, trace ids, subgraph_name, + correlation_id), not computed state values, so the swap is safe. + Mirrors the OTel harness's helper of the same name.""" + + def patch_nodes(graph_block: Mapping[str, Any] | None) -> None: + if not graph_block: + return + nodes = cast("dict[str, Any]", graph_block.get("nodes") or {}) + for node_spec_any in nodes.values(): + if not isinstance(node_spec_any, dict): + continue + node_spec = cast("dict[str, Any]", node_spec_any) + if "update_pure_from_state" in node_spec: + node_spec.pop("update_pure_from_state") + node_spec.setdefault("update_pure", {}) + + patch_nodes(spec) + if "subgraph" in spec: + patch_nodes(cast("Mapping[str, Any]", spec["subgraph"])) + for sub in cast("dict[str, Any]", spec.get("subgraphs") or {}).values(): + patch_nodes(cast("Mapping[str, Any]", sub)) + + def _compile_subgraphs(spec: Mapping[str, Any]) -> dict[str, Any]: """Build any subgraphs declared by the fixture and return a name→compiled-graph registry the adapter consumes. Mirrors the @@ -259,6 +279,14 @@ async def _run_case(case: Mapping[str, Any]) -> None: # LLM/prompt fixtures (022/023/024) use the simpler hand-rolled # per-node build that knows about ``calls_llm`` / ``renders_prompt``. if _has_topology_constructs(case): + # The topology fixtures (031/032/033) use inner-node test-seam + # directives the cross-capability adapter doesn't translate + # (``update_pure_from_state`` computes a value the assertions + # don't inspect — they check span / observation structure, not + # state values). Swap those for a benign ``update_pure: {}`` + # no-op so the graph is runnable, mirroring the OTel harness's + # ``_patch_unsupported_directives``. + _patch_unsupported_directives(case) subgraphs = _compile_subgraphs(case) built = build_graph(case, subgraphs=subgraphs, trace=[]) graph = built.builder.compile() diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 023146b..4aedc5f 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -9,7 +9,7 @@ def test_package_versions() -> None: assert openarmature.__version__ == "0.9.0" - assert openarmature.__spec_version__ == "0.26.1" + assert openarmature.__spec_version__ == "0.27.1" def test_spec_version_matches_pyproject() -> None: diff --git a/tests/unit/test_state_and_reducers.py b/tests/unit/test_state_and_reducers.py index 85c3fea..ab49380 100644 --- a/tests/unit/test_state_and_reducers.py +++ b/tests/unit/test_state_and_reducers.py @@ -5,7 +5,15 @@ import pytest from pydantic import Field, ValidationError -from openarmature.graph import END, State, append, last_write_wins, merge +from openarmature.graph import ( + END, + State, + append, + concat_flatten, + last_write_wins, + merge, + merge_all, +) from openarmature.graph.state import field_reducers, resolve_reducer @@ -58,3 +66,67 @@ def test_merge_reducer_rejects_non_mapping_prior() -> None: def test_merge_reducer_rejects_non_mapping_update() -> None: with pytest.raises(TypeError): merge({"k": "v"}, "not-a-dict") + + +# Proposal 0036 — concat_flatten (list-of-lists → flat list). + + +def test_concat_flatten_concatenates_and_flattens() -> None: + assert concat_flatten(["a", "b"], [["c"], ["d", "e"], []]) == ["a", "b", "c", "d", "e"] + + +def test_concat_flatten_empty_update_is_noop() -> None: + assert concat_flatten(["a"], []) == ["a"] + + +def test_concat_flatten_empty_sublists_contribute_nothing() -> None: + assert concat_flatten([], [[], []]) == [] + + +def test_concat_flatten_rejects_non_list_prior() -> None: + with pytest.raises(TypeError): + concat_flatten("not-a-list", [["x"]]) + + +def test_concat_flatten_rejects_non_list_update() -> None: + with pytest.raises(TypeError): + concat_flatten([], "not-a-list") + + +def test_concat_flatten_rejects_non_list_element() -> None: + with pytest.raises(TypeError): + concat_flatten([], [["a"], "not_a_list"]) + + +# Proposal 0036 — merge_all (list-of-mappings → folded mapping). + + +def test_merge_all_folds_with_last_write_wins() -> None: + result = merge_all( + {"seed": "prior", "retained": "kept"}, + [{"a": "1"}, {"seed": "overwritten", "b": "2"}, {"a": "1_wins"}], + ) + assert result == {"seed": "overwritten", "retained": "kept", "a": "1_wins", "b": "2"} + + +def test_merge_all_empty_update_is_noop() -> None: + assert merge_all({"k": "v"}, []) == {"k": "v"} + + +def test_merge_all_empty_mappings_contribute_nothing() -> None: + assert merge_all({"prior": "value"}, [{}, {}]) == {"prior": "value"} + + +def test_merge_all_rejects_non_mapping_prior() -> None: + with pytest.raises(TypeError): + merge_all("not-a-dict", [{"k": "v"}]) + + +def test_merge_all_rejects_non_list_update() -> None: + with pytest.raises(TypeError): + merge_all({}, {"k": "v"}) + + +def test_merge_all_rejects_non_mapping_element() -> None: + with pytest.raises(TypeError): + merge_all({}, [{"k": "1"}, "not_a_mapping"])