Skip to content

Commit 2c2ba1f

Browse files
Implement fan-out degrade contribution (0066) (#161)
Adopt proposal 0066: a degraded fan-out instance is a success whose contribution is its degraded_update, read in subgraph-field-name space. Make the per-instance partial uniformly subgraph-keyed across the extract, accumulator, resume, and fan-in paths, so a degrade's subgraph-space degraded_update composes through the same fan-in. This fixes a latent bug where an instance degraded_update's extra_outputs values were looked up by parent field name and silently dropped. A static degraded_update on an instance FailureIsolationMiddleware that omits collect_field is now a compile-time error (FanOutDegradedUpdateMissingCollectField); a callable degraded_update that omits it yields a graceful null slot rather than raising. Advance the pinned spec to v0.56.0, mark 0066 implemented, wire fixture 065 (four cases), and sync the spec-version declarations.
1 parent 44b38f9 commit 2c2ba1f

13 files changed

Lines changed: 131 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
1616
- **`RetryMiddleware` now takes a `RetryConfig` record** instead of individual constructor kwargs (proposal 0050 prep). The four retry settings (`max_attempts` / `classifier` / `backoff` / `on_retry`, each optional) move onto a frozen `RetryConfig`; construct as `RetryMiddleware(RetryConfig(max_attempts=...))`, while bare `RetryMiddleware()` still applies the defaults. This is a breaking change to the `RetryMiddleware` constructor. The record is the same shape the upcoming call-level `complete(retry=...)` parameter will accept, so one retry config serves both the per-node and per-call layers. `None` fields resolve to the canonical defaults (`default_classifier` / `exponential_jitter_backoff`) at use, preserving the prior behavior.
1717
- **Failure-isolation events report the originating cause's category at non-node placements** (proposal 0065, pipeline-utilities §6.3). When `FailureIsolationMiddleware` runs as instance middleware (§9.7), branch middleware (§11.7), or parent-node middleware on a fan-out / parallel-branches node, the graph engine has already wrapped the originating error as a `node_exception` carrier before the middleware catches it. `FailureIsolatedEvent.caught_exception.category` now resolves through that carrier (and any nested carriers) to the nearest categorized originating cause and reports its category instead of the masking `node_exception`, so the reported category agrees with what the §6.1 retry classifier acted on. For example, an instance whose retries exhaust on `provider_unavailable` now surfaces `provider_unavailable` rather than `node_exception`. The `message` tracks the resolved cause for category/message coherence. Node-level placement was already faithful and is unchanged, and catch/degrade behavior is unchanged at every site (only the event's reported cause changes). The wrapped-instance/branch lineage SHOULD (`fan_out_index` / `branch_name`) is deferred to a follow-up, since it needs the engine to surface per-instance identity to the wrapping-site middleware. `conformance.toml` marks proposal 0065 `implemented`, and conformance fixture 064 (three cases: the §9.7 instance and §11.7 branch sites plus an uncategorized cause) passes.
1818
- **Observer privacy flag `disable_llm_payload` renamed to `disable_provider_payload`** (proposal 0059, observability §5.5.4, spec v0.54.0). The observer-level flag on both bundled observers (`OTelObserver` and `LangfuseObserver`) is renamed, and its scope broadens from LLM-completion payload to any provider-call payload (LLM completion today; embedding and rerank when those land). This is a breaking change to both observer constructors: config passing `disable_llm_payload=True` (or `False`) updates to `disable_provider_payload=...` with no other change. The default stays `True` (payload suppressed), and the gating behavior for `LlmCompletionEvent` / `LlmFailedEvent` rendering is unchanged at every existing site. The rename is the only part of proposal 0059 adopted this cycle: the retrieval-provider capability itself (the `EmbeddingProvider` protocol, the `EmbeddingEvent` / `EmbeddingFailedEvent` typed variants, and the embedding span / observation mapping) is not yet implemented and rides as `not-yet` in `conformance.toml`. The §5.5.4 rename touches existing LLM-payload gating, so it lands with the pin.
19-
- **Pinned spec advances v0.53.0 → v0.55.1 across the v0.14.0 cycle**, in two steps: v0.54.0 (proposal 0059, the observer-flag rename above) and v0.55.1 (proposal 0065 above; the v0.55.1 patch also carries an observability §11 span-links text reconciliation that narrows an *Out of scope* bullet, with no python-observable change). `conformance.toml` records 0065 as `implemented` and 0059 as `not-yet` (only its cross-spec flag rename was adopted).
19+
- **Fan-out failure-isolation degrade contribution implemented** (proposal 0066, pipeline-utilities §9.3 / §9.8 / §11.7, spec v0.56.0). When `FailureIsolationMiddleware` degrades a fan-out instance, that instance is a success whose contribution is its `degraded_update`, read in subgraph-field-name space and never merged onto the failed instance's pre-failure state. This also fixes a latent bug: an instance `degraded_update`'s `extra_outputs` values were previously looked up by the parent field name and silently dropped (`collect_field` was unaffected). A static `degraded_update` that omits the node's `collect_field` is now a compile-time error (`FanOutDegradedUpdateMissingCollectField`); a callable `degraded_update` that omits it yields a graceful null slot rather than raising, preserving one collection slot per item. The parallel-branches counterpart (a branch `degraded_update` omitting a projected `outputs` field skips that field) was already correct as of the parallel-branches fix above and is now pinned by fixture 065. Success-path and resume behavior for correctly-configured fan-outs is unchanged.
20+
- **Pinned spec advances v0.53.0 → v0.56.0 across the v0.14.0 cycle**, in three steps: v0.54.0 (proposal 0059, the observer-flag rename above), v0.55.1 (proposal 0065 above; the v0.55.1 patch also carries an observability §11 span-links text reconciliation that narrows an *Out of scope* bullet, with no python-observable change), and v0.56.0 (proposal 0066, the fan-out degrade contribution above). `conformance.toml` records 0065 and 0066 as `implemented` and 0059 as `not-yet` (only its cross-spec flag rename was adopted).
2021

2122
### Fixed
2223

conformance.toml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
[manifest]
3434
implementation = "openarmature-python"
35-
spec_pin = "v0.55.1"
35+
spec_pin = "v0.56.0"
3636

3737
# Status values:
3838
# implemented — shipped behavior matches the proposal's contract
@@ -621,3 +621,17 @@ status = "not-yet"
621621
[proposals."0065"]
622622
status = "implemented"
623623
since = "0.14.0"
624+
625+
# Spec v0.56.0 (proposal 0066). Fan-out failure-isolation degrade
626+
# contribution (pipeline-utilities §9.3 / §9.8 / §11.7). A degraded
627+
# fan-out instance is a success whose contribution IS its degraded_update
628+
# (subgraph-space, read by subgraph field name; ``extra_outputs`` keyed by
629+
# subgraph field too, fixing a latent drop). A static degraded_update
630+
# omitting collect_field is a compile error
631+
# (``fan_out_degraded_update_missing_collect_field``); a callable that omits
632+
# it yields a graceful null slot (no raise). The §11.7 parallel-branches
633+
# counterpart skips an uncovered projected field. Fixture 065 (four
634+
# cases) passes.
635+
[proposals."0066"]
636+
status = "implemented"
637+
since = "0.14.0"

pyproject.toml

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

6565
[tool.openarmature]
66-
spec_version = "0.55.1"
66+
spec_version = "0.56.0"
6767

6868
[dependency-groups]
6969
dev = [

src/openarmature/AGENTS.md

Lines changed: 2 additions & 2 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.13.0 (spec v0.55.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.13.0 (spec v0.56.0). 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.55.1. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md` verbatim — including additions from accepted proposals that this Python implementation may not yet ship. For per-proposal implementation status (implemented / partial / textual-only / not-yet), see the `conformance.toml` manifest at the repo root. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
13+
_Sourced from openarmature-spec v0.56.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md` verbatim — including additions from accepted proposals that this Python implementation may not yet ship. For per-proposal implementation status (implemented / partial / textual-only / not-yet), see the `conformance.toml` manifest at the repo root. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
1414

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

src/openarmature/__init__.py

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

2727
__version__ = "0.13.0"
28-
__spec_version__ = "0.55.1"
28+
__spec_version__ = "0.56.0"
2929
# Proposal 0052 (spec observability §5.1 / §8.4.1): canonical
3030
# package-registry name for this implementation. Surfaces on every
3131
# OTel invocation span as ``openarmature.implementation.name`` and on

src/openarmature/graph/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
DanglingEdge,
1919
EdgeException,
2020
FanOutCountModeAmbiguous,
21+
FanOutDegradedUpdateMissingCollectField,
2122
FanOutEmpty,
2223
FanOutFieldNotList,
2324
FanOutInvalidConcurrency,
@@ -84,6 +85,7 @@
8485
"FailureIsolationMiddleware",
8586
"FanOutConfig",
8687
"FanOutCountModeAmbiguous",
88+
"FanOutDegradedUpdateMissingCollectField",
8789
"FanOutEmpty",
8890
"FanOutFieldNotList",
8991
"FanOutInvalidConcurrency",

src/openarmature/graph/builder.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
ConflictingReducers,
2525
DanglingEdge,
2626
FanOutCountModeAmbiguous,
27+
FanOutDegradedUpdateMissingCollectField,
2728
FanOutFieldNotList,
2829
MappingReferencesUndeclaredField,
2930
MultipleOutgoingEdges,
@@ -32,7 +33,7 @@
3233
UnreachableNode,
3334
)
3435
from .fan_out import ConcurrencyResolver, CountResolver, FanOutConfig, FanOutNode
35-
from .middleware import Middleware
36+
from .middleware import FailureIsolationMiddleware, Middleware
3637
from .nodes import FunctionNode, Node
3738
from .parallel_branches import BranchSpec, ParallelBranchesNode
3839
from .projection import FieldNameMatching, ProjectionStrategy
@@ -248,6 +249,22 @@ def add_fan_out_node[ChildT: State](
248249
direction="fan_out.extra_outputs", side="subgraph", field_name=sub_f
249250
)
250251

252+
# Materialize instance_middleware once so the degraded_update check
253+
# below and the FanOutConfig build don't both consume a one-shot
254+
# iterable.
255+
instance_middleware = tuple(instance_middleware or ())
256+
# §9.8: a degraded fan-out instance contributes its degraded_update
257+
# as the instance result, so a static (mapping) degraded_update on an
258+
# instance FailureIsolationMiddleware must cover collect_field. A
259+
# callable degraded_update is exempt — its output isn't knowable at
260+
# construction; an omitted collect_field yields a runtime null slot.
261+
for mw in instance_middleware:
262+
if not isinstance(mw, FailureIsolationMiddleware):
263+
continue
264+
degraded = mw.degraded_update
265+
if isinstance(degraded, Mapping) and collect_field not in cast("Mapping[str, Any]", degraded):
266+
raise FanOutDegradedUpdateMissingCollectField(node_name=name, collect_field=collect_field)
267+
251268
cfg = FanOutConfig(
252269
subgraph=subgraph,
253270
collect_field=collect_field,

src/openarmature/graph/errors.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,26 @@ def __init__(self, node_name: str, field_name: str) -> None:
123123
self.field_name = field_name
124124

125125

126+
class FanOutDegradedUpdateMissingCollectField(CompileError):
127+
"""Raised when a fan-out instance ``FailureIsolationMiddleware`` has a
128+
static (mapping) ``degraded_update`` that omits the node's
129+
``collect_field``. A degraded instance contributes its degraded_update
130+
as the instance result, so the collected field has to be present. A
131+
callable ``degraded_update`` is exempt: its output is not known at
132+
construction time, and an omitted collect_field yields a null slot at
133+
runtime instead of a failure."""
134+
135+
category = "fan_out_degraded_update_missing_collect_field"
136+
137+
def __init__(self, node_name: str, collect_field: str) -> None:
138+
super().__init__(
139+
f"fan-out node {node_name!r}: a static degraded_update on an instance "
140+
f"FailureIsolationMiddleware must include collect_field {collect_field!r}"
141+
)
142+
self.node_name = node_name
143+
self.collect_field = collect_field
144+
145+
126146
class ParallelBranchesNoBranches(CompileError):
127147
"""Raised at registration when a parallel-branches node's
128148
``branches`` mapping is empty. Per pipeline-utilities §11.9

src/openarmature/graph/fan_out.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -336,10 +336,13 @@ async def innermost(s: ChildT) -> Mapping[str, Any]:
336336
# (§10.11.1) depends on this ordering.
337337
tracked.result = partial.get(cfg.collect_field)
338338
tracked.result_is_error = False
339+
# ``partial`` is subgraph-space (success or degrade); read each
340+
# extra_outputs value by its subgraph field name and store the
341+
# accumulator entry under the parent field name.
339342
tracked.extra_outputs = {
340-
parent_field: partial[parent_field]
341-
for parent_field in cfg.extra_outputs
342-
if parent_field in partial
343+
parent_field: partial[sub_field]
344+
for parent_field, sub_field in cfg.extra_outputs.items()
345+
if sub_field in partial
343346
}
344347
tracked.state = "completed"
345348

@@ -576,11 +579,16 @@ def _extract_instance_partial(cfg: FanOutConfig, final_state: Any) -> Mapping[st
576579
"""Extract collect_field + extra_outputs values from a finished
577580
instance's state. Returned as the per-instance partial that flows
578581
up the instance_middleware chain."""
582+
# Per §9.3 the per-instance partial is subgraph-space: collect_field
583+
# and every extra_outputs SOURCE field are keyed by their subgraph
584+
# field name (the same shape a degrade's degraded_update carries), so
585+
# the success and degrade paths compose through one fan-in. The §9.4
586+
# projection to parent field names happens in the fan-in.
579587
partial: dict[str, Any] = {
580588
cfg.collect_field: getattr(final_state, cfg.collect_field),
581589
}
582-
for parent_field, sub_field in cfg.extra_outputs.items():
583-
partial[parent_field] = getattr(final_state, sub_field)
590+
for sub_field in cfg.extra_outputs.values():
591+
partial[sub_field] = getattr(final_state, sub_field)
584592
return partial
585593

586594

@@ -617,10 +625,13 @@ def _rolled_forward_partial(cfg: FanOutConfig, tracked: _FanOutInstanceState) ->
617625
verbatim — same shape as :func:`_extract_instance_partial` would
618626
have produced on the original run, sourced from the per-instance
619627
tracked state instead of a freshly-computed inner state."""
628+
# Reconstruct the subgraph-space partial: collect_field plus each
629+
# extra_outputs SOURCE field keyed by its subgraph name, sourced from
630+
# the parent-keyed accumulator entry.
620631
partial: dict[str, Any] = {cfg.collect_field: tracked.result}
621-
for parent_field in cfg.extra_outputs:
632+
for parent_field, sub_field in cfg.extra_outputs.items():
622633
if parent_field in tracked.extra_outputs:
623-
partial[parent_field] = tracked.extra_outputs[parent_field]
634+
partial[sub_field] = tracked.extra_outputs[parent_field]
624635
return partial
625636

626637

@@ -764,11 +775,15 @@ def _fan_in_fail_fast(
764775
the fail_fast policy. All ``results`` succeeded (otherwise gather
765776
would have raised), so the count is just ``len(results)``. Spec
766777
§9.3 + §9.4: instance-index order."""
778+
# §9.4 projection: read each instance's subgraph-space partial by
779+
# subgraph field name and collect into the parent field. ``.get`` keeps
780+
# an omitted collect_field (a callable degrade that doesn't set it, §9.3)
781+
# a graceful null slot rather than a raise.
767782
partial: dict[str, Any] = {
768-
cfg.target_field: [r[cfg.collect_field] for r in results],
783+
cfg.target_field: [r.get(cfg.collect_field) for r in results],
769784
}
770-
for parent_field in cfg.extra_outputs:
771-
partial[parent_field] = [r[parent_field] for r in results]
785+
for parent_field, sub_field in cfg.extra_outputs.items():
786+
partial[parent_field] = [r.get(sub_field) for r in results]
772787
if cfg.count_field is not None:
773788
partial[cfg.count_field] = len(results)
774789
return partial
@@ -806,10 +821,10 @@ def _fan_in_collect(
806821
successes.append(r)
807822

808823
partial: dict[str, Any] = {
809-
cfg.target_field: [s[cfg.collect_field] for s in successes],
824+
cfg.target_field: [s.get(cfg.collect_field) for s in successes],
810825
}
811-
for parent_field in cfg.extra_outputs:
812-
partial[parent_field] = [s[parent_field] for s in successes]
826+
for parent_field, sub_field in cfg.extra_outputs.items():
827+
partial[parent_field] = [s.get(sub_field) for s in successes]
813828
if cfg.errors_field is not None:
814829
partial[cfg.errors_field] = error_records
815830
if cfg.count_field is not None:

0 commit comments

Comments
 (0)