Skip to content

Commit 7e597f7

Browse files
graph: spec v0.4 fan-out runtime (proposal 0005 PU side) — phase 3 (#16)
* graph: spec v0.4 fan-out runtime (proposal 0005 PU side) — phase 3 Implements pipeline-utilities §9: parallel fan-out of compiled subgraphs with bounded concurrency, two error policies, configurable empty-input behavior, and per-instance middleware composition. Lands all 8 target conformance fixtures (pipeline-utilities 017-023 + graph-engine 017-observer-fan-out-index). Engine: - fan_out.py: FanOutNode (a third Node sibling alongside FunctionNode and SubgraphNode), FanOutConfig dataclass, per-instance projection helpers, concurrency-bounded execution via asyncio.gather + Semaphore, fan-in merge for fail_fast and collect policies, on_empty raise/noop handling. - errors.py: FanOutCountModeAmbiguous, FanOutFieldNotList compile errors; FanOutEmpty, FanOutInvalidCount, FanOutInvalidConcurrency runtime errors. The runtime trio subclasses NodeException so they surface as graph-engine §4 node_exception with an additional fan_out_category attribute (matching fixture 023's expected shape). - observer.py: _InvocationContext gains fan_out_index; new descend_into_fan_out_instance helper stamps the index onto the child context so inner-node events fire with it populated. - compiled.py: _step_fan_out_node — wraps the whole fan-out as one parent dispatch (per §9.6) with started/completed events around the chain; _dispatch_started/_completed propagate fan_out_index from context onto every NodeEvent. - builder.py: GraphBuilder.add_fan_out_node with full §9 compile- time validation (mode mutual-exclusion, items_field list-typed check, declared-field references for collect_field, target_field, count_field, errors_field, inputs, extra_outputs). Conformance harness: - adapter.py: fan_out node directive translation; new test seam directives update_pure, update_from_field, flaky_by_index (both fail_count_per_idx and fail_when_idx shapes), flaky_instance_only; _TracingFanOutNode for execution-order trace recording. - test_pipeline_utilities.py: instance_middleware threading via a new fan_out_instance_middleware kwarg on build_graph; cases- fixture handling now merges shared subgraph blocks into each case so 018-019, 021-023 see them; supports state_field_read and queue_chunk callable resolvers for count + concurrency. - test_conformance.py: removed fan_out from _UNSUPPORTED_NODE_DIRECTIVES so graph-engine fixture 017 runs. Unit tests (test_fan_out.py): 19 tests covering items_field projection, count modes (literal int + state-reading callable), count + concurrency callables resolved exactly once at entry, inputs mapping projection, concurrency limit enforcement, fail_fast recoverable_state contract, collect errors_field shape, on_empty raise/noop, count_field write, extra_outputs merge, instance_middleware retry composition, fan-in determinism under nondeterministic completion timing, four compile-error checks (count_mode_ambiguous both/neither, field_not_list, inputs/extra_outputs undeclared field references). Total tests: 276 passing, 0 skipped. * test: document fixture-global counter limitation in flaky test seams The PR review thread suggested keying flaky_by_index and flaky_instance_only by id(state) for per-instance counters. Tried that; broke fixture 021. Root cause: instance-level retry (021) constructs a fresh subgraph state on each retry, so id(state) resets per retry attempt — the per-instance counter starts over and retry exhausts. True per-instance semantics need an identifier stable across both node-level retry (state stable, id works) AND instance-level retry (state changes, id doesn't work). A state-field key would work but the field name is fixture-specific (item for 020, input for 021). Reverting to a fixture-global counter and documenting the limitation in the docstring + an inline comment, so a future fixture exercising the gap surfaces a real failure rather than silently miscounting. The existing fixtures (019 collect, 020 node-level retry, 021 instance-level retry) align with the global counter at runtime — not because the semantics are correct, but because the timing happens to land the failure on the right call.
1 parent 89f5277 commit 7e597f7

10 files changed

Lines changed: 1886 additions & 29 deletions

File tree

src/openarmature/graph/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
ConflictingReducers,
1515
DanglingEdge,
1616
EdgeException,
17+
FanOutCountModeAmbiguous,
18+
FanOutEmpty,
19+
FanOutFieldNotList,
20+
FanOutInvalidConcurrency,
21+
FanOutInvalidCount,
1722
GraphError,
1823
MappingReferencesUndeclaredField,
1924
MultipleOutgoingEdges,
@@ -26,6 +31,7 @@
2631
UnreachableNode,
2732
)
2833
from .events import NodeEvent
34+
from .fan_out import FanOutConfig, FanOutNode
2935
from .middleware import (
3036
Middleware,
3137
RetryMiddleware,
@@ -51,6 +57,13 @@
5157
"EdgeException",
5258
"EndSentinel",
5359
"ExplicitMapping",
60+
"FanOutConfig",
61+
"FanOutCountModeAmbiguous",
62+
"FanOutEmpty",
63+
"FanOutFieldNotList",
64+
"FanOutInvalidConcurrency",
65+
"FanOutInvalidCount",
66+
"FanOutNode",
5467
"FieldNameMatching",
5568
"FunctionNode",
5669
"GraphBuilder",

src/openarmature/graph/builder.py

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,22 @@
1111
"""
1212

1313
from collections.abc import Awaitable, Callable, Iterable, Mapping
14-
from typing import Any, Self, cast
14+
from types import GenericAlias, UnionType
15+
from typing import Any, Self, cast, get_args, get_origin
1516

1617
from .compiled import CompiledGraph
1718
from .edges import ConditionalEdge, EndSentinel, StaticEdge
1819
from .errors import (
1920
ConflictingReducers,
2021
DanglingEdge,
22+
FanOutCountModeAmbiguous,
23+
FanOutFieldNotList,
24+
MappingReferencesUndeclaredField,
2125
MultipleOutgoingEdges,
2226
NoDeclaredEntry,
2327
UnreachableNode,
2428
)
29+
from .fan_out import ConcurrencyResolver, CountResolver, FanOutConfig, FanOutNode
2530
from .middleware import Middleware
2631
from .nodes import FunctionNode, Node
2732
from .projection import FieldNameMatching, ProjectionStrategy
@@ -79,6 +84,156 @@ def add_subgraph_node[ChildT: State](
7984
)
8085
return self
8186

87+
def add_fan_out_node[ChildT: State](
88+
self,
89+
name: str,
90+
*,
91+
subgraph: CompiledGraph[ChildT],
92+
collect_field: str,
93+
target_field: str,
94+
items_field: str | None = None,
95+
item_field: str | None = None,
96+
count: int | CountResolver | None = None,
97+
concurrency: int | ConcurrencyResolver | None = 10,
98+
error_policy: str = "fail_fast",
99+
on_empty: str = "raise",
100+
count_field: str | None = None,
101+
inputs: Mapping[str, str] | None = None,
102+
extra_outputs: Mapping[str, str] | None = None,
103+
instance_middleware: Iterable[Middleware] | None = None,
104+
errors_field: str | None = None,
105+
middleware: Iterable[Middleware] | None = None,
106+
) -> Self:
107+
"""Register a fan-out node per pipeline-utilities §9.
108+
109+
Validates configuration at registration time:
110+
111+
- Exactly one of ``items_field`` or ``count`` MUST be specified
112+
(``fan_out_count_mode_ambiguous`` otherwise).
113+
- ``items_field`` MUST refer to a list-typed field on the parent
114+
state schema (``fan_out_field_not_list`` otherwise).
115+
- ``items_field`` mode requires ``item_field``; ``count`` mode
116+
forbids ``item_field``.
117+
- ``on_empty`` and ``error_policy`` MUST be one of the
118+
spec-defined string literals.
119+
- ``inputs`` / ``extra_outputs`` / ``count_field`` field
120+
references go through the existing
121+
``mapping_references_undeclared_field`` rule.
122+
123+
See spec §9 for full field semantics.
124+
"""
125+
if name in self._nodes:
126+
raise ValueError(f"node {name!r} already declared")
127+
128+
# Mode validation: exactly one of items_field / count.
129+
if (items_field is None) == (count is None):
130+
raise FanOutCountModeAmbiguous(
131+
node_name=name,
132+
message=(
133+
"must specify exactly one of items_field or count "
134+
f"(got items_field={items_field!r}, count={count!r})"
135+
),
136+
)
137+
if items_field is not None and item_field is None:
138+
raise FanOutCountModeAmbiguous(node_name=name, message="items_field mode requires item_field")
139+
if count is not None and item_field is not None:
140+
raise FanOutCountModeAmbiguous(node_name=name, message="count mode forbids item_field")
141+
142+
# items_field must be a list-typed parent field.
143+
if items_field is not None:
144+
parent_fields = self.state_cls.model_fields
145+
if items_field not in parent_fields:
146+
raise MappingReferencesUndeclaredField(
147+
direction="fan_out.items_field", side="parent", field_name=items_field
148+
)
149+
ann = parent_fields[items_field].annotation
150+
if not _is_list_typed(ann):
151+
raise FanOutFieldNotList(node_name=name, field_name=items_field)
152+
153+
# error_policy + on_empty literal validation.
154+
if error_policy not in {"fail_fast", "collect"}:
155+
raise ValueError(
156+
f"fan-out node {name!r}: error_policy must be 'fail_fast' or 'collect', got {error_policy!r}"
157+
)
158+
if on_empty not in {"raise", "noop"}:
159+
raise ValueError(f"fan-out node {name!r}: on_empty must be 'raise' or 'noop', got {on_empty!r}")
160+
161+
# *_field references must match declared fields.
162+
parent_fields = self.state_cls.model_fields
163+
sub_fields = subgraph.state_cls.model_fields
164+
if target_field not in parent_fields:
165+
raise MappingReferencesUndeclaredField(
166+
direction="fan_out.target_field", side="parent", field_name=target_field
167+
)
168+
if collect_field not in sub_fields:
169+
raise MappingReferencesUndeclaredField(
170+
direction="fan_out.collect_field", side="subgraph", field_name=collect_field
171+
)
172+
# NOTE: item_field is intentionally NOT validated against declared
173+
# subgraph fields. Per fixture 023, the spec allows item_field to
174+
# name a field the subgraph doesn't declare (treated as a
175+
# placeholder when the subgraph doesn't read the item). The
176+
# runtime projection in fan_out._build_instance_states skips the
177+
# assignment if the field isn't declared, so non-declared
178+
# item_field values are effectively no-ops.
179+
if count_field is not None and count_field not in parent_fields:
180+
raise MappingReferencesUndeclaredField(
181+
direction="fan_out.count_field", side="parent", field_name=count_field
182+
)
183+
if errors_field is not None and errors_field not in parent_fields:
184+
raise MappingReferencesUndeclaredField(
185+
direction="fan_out.errors_field", side="parent", field_name=errors_field
186+
)
187+
for sub_f, parent_f in (inputs or {}).items():
188+
if sub_f not in sub_fields:
189+
raise MappingReferencesUndeclaredField(
190+
direction="fan_out.inputs", side="subgraph", field_name=sub_f
191+
)
192+
if parent_f not in parent_fields:
193+
raise MappingReferencesUndeclaredField(
194+
direction="fan_out.inputs", side="parent", field_name=parent_f
195+
)
196+
for parent_f, sub_f in (extra_outputs or {}).items():
197+
if parent_f not in parent_fields:
198+
raise MappingReferencesUndeclaredField(
199+
direction="fan_out.extra_outputs", side="parent", field_name=parent_f
200+
)
201+
if sub_f not in sub_fields:
202+
raise MappingReferencesUndeclaredField(
203+
direction="fan_out.extra_outputs", side="subgraph", field_name=sub_f
204+
)
205+
206+
cfg = FanOutConfig(
207+
subgraph=subgraph,
208+
collect_field=collect_field,
209+
target_field=target_field,
210+
items_field=items_field,
211+
item_field=item_field,
212+
count=count,
213+
concurrency=concurrency,
214+
error_policy=cast(Any, error_policy),
215+
on_empty=cast(Any, on_empty),
216+
count_field=count_field,
217+
inputs=dict(inputs or {}),
218+
extra_outputs=dict(extra_outputs or {}),
219+
instance_middleware=tuple(instance_middleware or ()),
220+
errors_field=errors_field,
221+
)
222+
# FanOutNode satisfies the Node[StateT] structural protocol (run
223+
# returns a partial update; name and middleware are present),
224+
# but pyright loses the StateT correspondence through the second
225+
# type parameter — cast restores it for the dict assignment.
226+
fan_out: Node[StateT] = cast(
227+
"Node[StateT]",
228+
FanOutNode[StateT, ChildT](
229+
name=name,
230+
config=cfg,
231+
middleware=tuple(middleware) if middleware is not None else (),
232+
),
233+
)
234+
self._nodes[name] = fan_out
235+
return self
236+
82237
def add_middleware(self, middleware: Middleware) -> Self:
83238
"""Register a per-graph middleware applied to every node in this graph.
84239
@@ -195,3 +350,28 @@ def _reachable_nodes(
195350
reachable.add(name)
196351
frontier.append(name)
197352
return reachable
353+
354+
355+
def _is_list_typed(annotation: Any) -> bool:
356+
"""True if ``annotation`` resolves to a ``list[...]`` shape.
357+
358+
Used by ``add_fan_out_node`` to validate that ``items_field`` refers
359+
to a list-typed parent field. Handles both bare ``list[X]`` and
360+
``Annotated[list[X], reducer]`` forms (the latter is how state
361+
fields commonly attach an `append` reducer).
362+
"""
363+
if annotation is list:
364+
return True
365+
origin = get_origin(annotation)
366+
if origin is list:
367+
return True
368+
# Annotated[T, ...] — peel the metadata, recurse on the type.
369+
if isinstance(annotation, GenericAlias):
370+
return False
371+
args = get_args(annotation)
372+
if args and origin is None:
373+
# Likely Annotated; first arg is the underlying type.
374+
return _is_list_typed(args[0])
375+
if isinstance(annotation, UnionType):
376+
return any(_is_list_typed(a) for a in args)
377+
return False

src/openarmature/graph/compiled.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
StateValidationError,
3939
)
4040
from .events import NodeEvent
41+
from .fan_out import FanOutNode
4142
from .middleware import ChainCall, Middleware, compose_chain
4243
from .nodes import Node
4344
from .observer import (
@@ -257,7 +258,15 @@ async def _invoke(
257258
while True:
258259
node = self.nodes[current]
259260

260-
if isinstance(node, SubgraphNode):
261+
if isinstance(node, FanOutNode):
262+
# Fan-out nodes are recognized as a distinct node type
263+
# per pipeline-utilities §9. Dispatched through
264+
# ``_step_fan_out_node`` which wraps the whole fan-out
265+
# as one parent dispatch (per §9.6) — instance-level
266+
# concurrency lives inside the FanOutNode itself.
267+
fn_node = cast("FanOutNode[StateT, State]", node)
268+
state = await self._step_fan_out_node(fn_node, current, state, context)
269+
elif isinstance(node, SubgraphNode):
261270
# Subgraph wrappers are transparent to the observer protocol
262271
# (per fixture 013): no event is dispatched for the wrapper
263272
# itself, the step counter does not advance for it, and any
@@ -457,6 +466,62 @@ async def innermost(s: Any) -> Mapping[str, Any]:
457466
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
458467
return _merge_partial(state, final_partial, self.reducers, current)
459468

469+
async def _step_fan_out_node(
470+
self,
471+
node: FanOutNode[StateT, State],
472+
current: str,
473+
state: StateT,
474+
context: _InvocationContext,
475+
) -> StateT:
476+
"""Run one fan-out-as-node step through the parent's middleware chain.
477+
478+
Per pipeline-utilities §9.6: the parent's per-graph + per-node
479+
middleware wraps the fan-out as a SINGLE dispatch — one started
480+
event before the fan-out begins, one completed event after all
481+
instances complete and fan-in is done. Per-instance events
482+
come from the inner subgraph executions; their pre_state /
483+
post_state shape is the inner subgraph's state, and they carry
484+
``fan_out_index`` populated.
485+
486+
Raw exceptions escaping the chain become NodeException per §4.
487+
"""
488+
step = context.take_step()
489+
namespace = context.namespace_prefix + (current,)
490+
491+
async def innermost(s: Any) -> Mapping[str, Any]:
492+
self._dispatch_started(context, current, namespace, step, s)
493+
try:
494+
partial = await node.run_with_context(s, context)
495+
except RuntimeGraphError as e:
496+
self._dispatch_completed(context, current, namespace, step, s, error=e)
497+
raise
498+
except Exception as e:
499+
wrapped = NodeException(node_name=current, cause=e, recoverable_state=s)
500+
self._dispatch_completed(context, current, namespace, step, s, error=wrapped)
501+
raise wrapped from e
502+
503+
try:
504+
merged = _merge_partial(s, partial, self.reducers, current)
505+
except (ReducerError, StateValidationError) as e:
506+
self._dispatch_completed(context, current, namespace, step, s, error=e)
507+
raise
508+
509+
self._dispatch_completed(context, current, namespace, step, s, post_state=merged)
510+
return partial
511+
512+
chain: ChainCall = compose_chain(
513+
list(self.middleware) + list(node.middleware),
514+
innermost,
515+
)
516+
517+
try:
518+
final_partial = await chain(state)
519+
except RuntimeGraphError:
520+
raise
521+
except Exception as e:
522+
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
523+
return _merge_partial(state, final_partial, self.reducers, current)
524+
460525
@staticmethod
461526
def _dispatch_started(
462527
context: _InvocationContext,
@@ -479,6 +544,7 @@ def _dispatch_started(
479544
error=None,
480545
parent_states=context.parent_states_prefix,
481546
attempt_index=attempt_index,
547+
fan_out_index=context.fan_out_index,
482548
),
483549
)
484550

@@ -506,5 +572,6 @@ def _dispatch_completed(
506572
error=error,
507573
parent_states=context.parent_states_prefix,
508574
attempt_index=attempt_index,
575+
fan_out_index=context.fan_out_index,
509576
),
510577
)

0 commit comments

Comments
 (0)