Skip to content

Commit 28e80b4

Browse files
graph: phase 6.1 PR-C.1 — proposal 0012 + v0.9.0
Implement spec graph-engine v0.9.0 (proposal 0012): the engine's ``completed`` observer event for the just-completed node now dispatches AFTER edge evaluation completes, not between merge and edge eval. Edge-resolution failures (``routing_error``, ``edge_exception``) populate the ``error`` field of the preceding node's ``completed`` event, sharing the started/completed pair rather than producing a separate event pair (per §3 step 3 + §6 revisions in proposal 0012). The existing observer ``_handle_completed`` ERROR-mapping path picks up the new error categories automatically; no observer change required. Mechanism: introduce ``_StepResult(state, finalize_completed)`` returned by the per-step dispatchers. Failure-path dispatches (``node_exception`` / ``reducer_error`` / ``state_validation_error``) stay inline inside ``innermost`` — those errors short-circuit before edge eval can run, preserving §3's "before the failure propagates to the caller" MUST. On success, ``innermost`` stores ``(attempt_index, pre_state, merged)`` for the FINAL successful attempt; the outer scope builds a ``finalize_completed`` closure that ``_invoke`` calls AFTER edge eval, passing either ``None`` (success → dispatch completed with ``post_state``) or the edge error (dispatch completed with ``error`` populated). ``_step_subgraph_node`` is unchanged in behavior — the wrapper is transparent per fixture 013. Returns ``_StepResult(state, _no_op_finalize)`` so edge errors after a transparent wrapper propagate silently per §4 without an observer event pair (proposal 0012's "share the preceding node's pair" MUST is conditional on the pair existing). Same for middleware that short-circuits without invoking ``next``. Inline docstring at the wrapper's _StepResult return anchors the contract for future readers. Pin sites bumped to v0.9.0 (three-place sync per CLAUDE.md): ``openarmature-spec`` submodule pointer to ``78fe9b4``, ``pyproject.toml`` ``tool.openarmature.spec_version``, ``src/openarmature/__init__.py`` ``__spec_version__``, ``tests/test_smoke.py`` drift-guard literal. The ``OTelObserver.spec_version`` attribute updates automatically via ``_read_spec_version`` (per PR-A). Tests: - New unit tests in ``tests/unit/test_runtime_errors.py`` verify the new event shape: exactly one started + one completed for the preceding node, completed has ``error`` populated and ``post_state`` absent, downstream node never fires events. - ``tests/conformance/test_conformance.py`` gains a ``_run_fixture_020`` driver for the new graph-engine fixture ``020-observer-edge-error-events`` (two sub-cases: routing_error and edge_exception). The fixture's edge-condition ``callable:`` directives don't match the adapter DSL; the driver translates the semantics directly. Cases-shape support added to the generic harness for future cases-shape graph-engine fixtures. - ``tests/conformance/test_observability.py`` removes ``004-otel-routing-error-attribution`` from ``_DEFERRED_FIXTURES`` (was deferred through Phase 6.1 PR-C pending this proposal) and adds a ``_run_fixture_004`` driver. Verifies preceding node's span ends ERROR with ``status_description == "routing_error"`` + recorded exception event + ``openarmature.error.category`` attribute, no separate edge-function span, invocation span propagates ERROR. - ``tests/conformance/harness/expectations.py``: ``GraphEngineExpected`` gains an ``invariants`` field so fixture 020's ``invariants:`` block parses cleanly. 398 tests pass (was 392; net +6 from new fixture drivers + unit tests + fixture-020 sub-cases that previously errored). 4 skipped (was 5; fixture 004 now driven). Pyright clean. PR-C.2 (fan-out per-instance dispatch + fixture 006) and PR-C.3 (observer ``prepare_sync`` + fixture 010) sit independently behind their respective architectural pieces; land in either order after this PR ships. Phase 6.1 closes when all three merge.
1 parent 8917cad commit 28e80b4

9 files changed

Lines changed: 561 additions & 58 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Repository = "https://github.com/LunarCommand/openarmature-python"
3636
Specification = "https://github.com/LunarCommand/openarmature-spec"
3737

3838
[tool.openarmature]
39-
spec_version = "0.8.2"
39+
spec_version = "0.9.0"
4040

4141
[dependency-groups]
4242
dev = [

src/openarmature/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""OpenArmature — workflow framework for LLM pipelines and tool-calling agents."""
22

33
__version__ = "0.4.0rc0"
4-
__spec_version__ = "0.8.2"
4+
__spec_version__ = "0.9.0"

src/openarmature/graph/compiled.py

Lines changed: 219 additions & 47 deletions
Large diffs are not rendered by default.

tests/conformance/harness/expectations.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ class GraphEngineExpected(_ForbidExtras):
4848
observer_events: Any = None
4949
delivery_order: list[dict[str, Any]] | None = None
5050
observer_event_invariants: dict[str, Any] | None = None
51+
# 020 — proposal-0012 fixture: assertions about edge-resolution
52+
# failure event shapes. Permissive dict until Phase 1.
53+
invariants: dict[str, Any] | None = None
5154
# 015 — invoke() returns normally; obs_raiser's exceptions surface to
5255
# warnings rather than propagate.
5356
no_propagated_error: bool | None = None

tests/conformance/test_conformance.py

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,26 @@
77

88
from __future__ import annotations
99

10+
from collections.abc import Mapping
1011
from pathlib import Path
1112
from typing import Any, cast
1213

1314
import pytest
1415
import yaml
1516

1617
from openarmature.graph import (
18+
END,
1719
CompileError,
20+
EdgeException,
21+
EndSentinel,
22+
GraphBuilder,
1823
NodeException,
1924
RoutingError,
2025
RuntimeGraphError,
26+
State,
2127
SubscribedObserver,
2228
)
29+
from openarmature.graph.events import NodeEvent
2330
from openarmature.graph.observer import Observer
2431

2532
from .adapter import (
@@ -71,7 +78,7 @@ def _fixture_id(path: Path) -> str:
7178
)
7279

7380

74-
def _unsupported_directive(spec: dict[str, Any]) -> str | None:
81+
def _unsupported_directive(spec: Mapping[str, Any]) -> str | None:
7582
"""Return the first node directive the legacy adapter can't translate,
7683
or None if every node uses one of the directives it handles. Walks
7784
both the top-level graph and an optional inner ``subgraph`` block."""
@@ -162,11 +169,34 @@ def _compile_subgraphs_map(
162169
async def test_runtime_fixture(fixture_path: Path) -> None:
163170
spec = _load(fixture_path)
164171

172+
# ``cases:`` form (e.g., 020-observer-edge-error-events): each entry
173+
# is a self-contained per-case spec. Iterate; treat each case as a
174+
# standalone fixture body. Wrapping AssertionError with the case
175+
# name keeps failure messages locatable.
176+
# Fixture 020 uses edge-condition `callable:` directives
177+
# (`state_field_read`, `edge_raises`) that are unique to this fixture
178+
# and don't fit the generic adapter DSL. Custom driver below.
179+
if fixture_path.stem == "020-observer-edge-error-events":
180+
await _run_fixture_020(spec)
181+
return
182+
183+
if "cases" in spec:
184+
for case in cast("list[dict[str, Any]]", spec["cases"]):
185+
try:
186+
await _run_runtime_case(case, fixture_path.stem)
187+
except AssertionError as e:
188+
raise AssertionError(f"case {case.get('name')!r}: {e}") from e
189+
return
190+
191+
await _run_runtime_case(spec, fixture_path.stem)
192+
193+
194+
async def _run_runtime_case(spec: Mapping[str, Any], fixture_id: str) -> None:
165195
# Skip fixtures whose nodes use directives the legacy adapter doesn't
166196
# translate (fan_out, flaky variants, calls_llm, etc.). Each directive
167197
# is gated to the phase that lands its runtime support.
168198
if (hit := _unsupported_directive(spec)) is not None:
169-
pytest.skip(f"{fixture_path.stem}: unsupported node directive {hit}")
199+
pytest.skip(f"{fixture_id}: unsupported node directive {hit}")
170200

171201
# Subgraph fixtures (006, 011, 013) declare an inner subgraph via the
172202
# singular `subgraph:` key. Fixture 019 introduces the plural `subgraphs:`
@@ -292,6 +322,117 @@ async def test_runtime_fixture(fixture_path: Path) -> None:
292322
)
293323

294324

325+
# ---------------------------------------------------------------------------
326+
# Fixture 020 — observer edge-error events (proposal 0012 / spec v0.9.0)
327+
#
328+
# Two sub-cases verifying §3 step 3 (revised) + §6 (revised): edge-resolution
329+
# failures (routing_error, edge_exception) land on the preceding node's
330+
# completed event with `error` populated, sharing the started/completed pair
331+
# rather than producing a separate event pair.
332+
#
333+
# Custom driver (rather than the generic harness) because fixture 020's
334+
# edge-condition `callable:` directives (`state_field_read`, `edge_raises`)
335+
# don't match the adapter DSL's `if_field/equals/then/else` shape — these
336+
# directives are spec-defined just for this fixture's two sub-cases. The
337+
# semantic intent is captured directly here.
338+
# ---------------------------------------------------------------------------
339+
340+
341+
async def _run_fixture_020(spec: Mapping[str, Any]) -> None:
342+
cases = cast("list[dict[str, Any]]", spec["cases"])
343+
for case in cases:
344+
case_name = cast("str", case["name"])
345+
try:
346+
await _run_fixture_020_case(case)
347+
except AssertionError as e:
348+
raise AssertionError(f"case {case_name!r}: {e}") from e
349+
350+
351+
async def _run_fixture_020_case(case: Mapping[str, Any]) -> None:
352+
"""Build a two-node graph (a → b) with the case's edge-condition
353+
directive translated to a Python edge function, then assert the
354+
proposal-0012 contract on the captured observer events."""
355+
356+
class FixtureState(State):
357+
x: int = 0
358+
359+
received: list[NodeEvent] = []
360+
361+
async def observer(event: NodeEvent) -> None:
362+
received.append(event)
363+
364+
async def node_a(_state: Any) -> dict[str, Any]:
365+
return {"x": 1}
366+
367+
async def node_b(_state: Any) -> dict[str, Any]:
368+
return {"x": 2}
369+
370+
cond = cast("dict[str, Any]", case["edges"][0]["condition"])
371+
callable_name = cast("str", cond["callable"])
372+
if callable_name == "state_field_read":
373+
# Per spec proposal 0012 fixture 020: the edge function returns
374+
# the value from initial_state's named field. The fixture's case
375+
# initial_state populates that field with a string that is NOT a
376+
# declared node name in the graph, so the engine raises
377+
# ``RoutingError``. We reproduce the semantic via a closure
378+
# over the initial_state value.
379+
initial_state_dict = cast("dict[str, Any]", case.get("initial_state", {}))
380+
field_name = cast("str", cond["field"])
381+
target_value = cast("str", initial_state_dict[field_name])
382+
383+
def edge_fn(_state: Any) -> str | EndSentinel:
384+
return target_value
385+
elif callable_name == "edge_raises":
386+
message = cast("str", cond.get("message", "edge raised"))
387+
388+
def edge_fn(_state: Any) -> str | EndSentinel:
389+
raise RuntimeError(message)
390+
else:
391+
raise AssertionError(f"fixture 020: unknown condition callable {callable_name!r}")
392+
393+
g = (
394+
GraphBuilder(FixtureState)
395+
.add_node("a", node_a)
396+
.add_node("b", node_b)
397+
.add_conditional_edge("a", edge_fn)
398+
.add_edge("b", END)
399+
.set_entry("a")
400+
.compile()
401+
)
402+
g.attach_observer(observer)
403+
404+
expected_error = cast("dict[str, Any]", case["expected_error"])
405+
expected_category = cast("str", expected_error["category"])
406+
407+
with pytest.raises(RuntimeGraphError) as excinfo:
408+
await g.invoke(FixtureState())
409+
await g.drain()
410+
411+
err = excinfo.value
412+
assert err.category == expected_category
413+
if expected_category == "routing_error":
414+
assert isinstance(err, RoutingError)
415+
elif expected_category == "edge_exception":
416+
assert isinstance(err, EdgeException)
417+
418+
# Per the revised §6 contract: edge-resolution failures share the
419+
# preceding node's started/completed pair. Node a fires exactly one
420+
# started + one completed event; node b never fires.
421+
a_events = [e for e in received if e.node_name == "a"]
422+
b_events = [e for e in received if e.node_name == "b"]
423+
assert len(a_events) == 2, f"expected 2 events for node a; got {len(a_events)}"
424+
assert b_events == [], f"node b MUST never fire events on edge-resolution failure; got {b_events}"
425+
426+
started, completed = a_events
427+
assert started.phase == "started"
428+
assert completed.phase == "completed"
429+
assert completed.post_state is None, (
430+
"completed event MUST have post_state absent when edge resolution fails"
431+
)
432+
assert completed.error is not None
433+
assert completed.error.category == expected_category
434+
435+
295436
# ---------------------------------------------------------------------------
296437
# 007 compile-errors: one parametrized case per entry in the `cases:` table.
297438
# ---------------------------------------------------------------------------

tests/conformance/test_observability.py

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"001-otel-basic-trace",
7171
"002-otel-subgraph-hierarchy",
7272
"003-otel-error-status",
73+
"004-otel-routing-error-attribution",
7374
"005-otel-llm-provider-span-nested",
7475
"007-otel-retry-attempt-spans",
7576
"008-otel-detached-trace-mode",
@@ -80,11 +81,6 @@
8081

8182

8283
_DEFERRED_FIXTURES: dict[str, str] = {
83-
"004-otel-routing-error-attribution": (
84-
"Awaiting proposal 0012 — routing-error attribution requires the §3/§6 "
85-
"ordering swap (completed dispatch after edge eval) so RoutingError lands "
86-
"on the preceding node's completed event. Lands in PR-C.1 once v0.9.0 ships."
87-
),
8884
"006-otel-fan-out-instance-attribution": (
8985
"Needs non-detached fan-out per-instance dispatch span synthesis + "
9086
"FanOutConfig metadata surfacing per spec §5.4 (current observer opens one "
@@ -139,6 +135,8 @@ async def test_observability_fixture(fixture_path: Path) -> None:
139135
await _run_fixture_002(spec)
140136
elif fixture_id == "003-otel-error-status":
141137
await _run_fixture_003(spec)
138+
elif fixture_id == "004-otel-routing-error-attribution":
139+
await _run_fixture_004(spec)
142140
elif fixture_id == "005-otel-llm-provider-span-nested":
143141
await _run_fixture_005(spec)
144142
elif fixture_id == "007-otel-retry-attempt-spans":
@@ -374,6 +372,84 @@ async def _run_fixture_003(spec: Mapping[str, Any]) -> None:
374372
)
375373

376374

375+
# ---------------------------------------------------------------------------
376+
# Fixture 004 — routing-error attribution (proposal 0012 / spec v0.9.0)
377+
# ---------------------------------------------------------------------------
378+
379+
380+
async def _run_fixture_004(spec: Mapping[str, Any]) -> None:
381+
"""Spec §4.2 + spec v0.9.0 / proposal 0012: routing errors land on
382+
the preceding node's ``completed`` event with ``error`` populated
383+
(sharing the started/completed pair rather than producing a
384+
separate one). The OTel observer's existing
385+
``_handle_completed`` ERROR-mapping path picks this up
386+
automatically — no observer-side change needed for the swap.
387+
388+
Driver verifies: the ``pick`` node's span ends ERROR with
389+
``status_description == "routing_error"``, an ``exception``
390+
event recorded, and the ``openarmature.error.category``
391+
attribute. No span for the edge function (no ``edge_spans``)
392+
per §4.2's "edge logic folded into the preceding node span"
393+
framing."""
394+
from opentelemetry.trace import StatusCode
395+
396+
from openarmature.graph import RuntimeGraphError
397+
398+
observer, exporter = _build_observer()
399+
trace_log: list[str] = []
400+
built = build_graph(spec, trace=trace_log)
401+
compiled = built.builder.compile()
402+
compiled.attach_observer(observer)
403+
initial_state = built.initial_state(spec.get("initial_state", {}))
404+
with pytest.raises(RuntimeGraphError) as excinfo:
405+
await compiled.invoke(initial_state)
406+
assert excinfo.value.category == "routing_error"
407+
await compiled.drain()
408+
observer.shutdown()
409+
spans = exporter.get_finished_spans()
410+
411+
by_name = {s.name: s for s in spans}
412+
413+
pick = by_name.get("pick")
414+
assert pick is not None
415+
assert pick.status.status_code == StatusCode.ERROR, (
416+
f"preceding node 'pick' span MUST be ERROR; got {pick.status.status_code}"
417+
)
418+
assert pick.status.description == "routing_error", (
419+
f"preceding node 'pick' span status_description MUST be 'routing_error'; "
420+
f"got {pick.status.description!r}"
421+
)
422+
pick_attrs = dict(pick.attributes or {})
423+
assert pick_attrs.get("openarmature.error.category") == "routing_error"
424+
# Exception event recorded on the span via record_exception.
425+
exception_events = [e for e in pick.events if e.name == "exception"]
426+
event_names = [e.name for e in pick.events]
427+
assert len(exception_events) >= 1, (
428+
f"'pick' MUST have at least one 'exception' event recorded; got {event_names}"
429+
)
430+
431+
# Per fixture 004's "no_edge_spans: true" — the edge function
432+
# itself does not produce a separate span; the routing error is
433+
# folded into the preceding node's span.
434+
edge_span_names = {"edge", "openarmature.edge", "edge_function"}
435+
edge_spans = [s for s in spans if s.name in edge_span_names]
436+
assert edge_spans == [], (
437+
f"there MUST be no separate edge-function spans per §4.2; got {[s.name for s in edge_spans]}"
438+
)
439+
440+
# Unreachable nodes never fire spans (they were unreached).
441+
for unreachable in ("unreachable_a", "unreachable_b"):
442+
assert unreachable not in by_name, f"{unreachable!r} MUST not produce a span — never reached"
443+
444+
# Invocation span ends ERROR per the §4.2 invocation-status
445+
# propagation contract (PR-C review fix).
446+
inv = by_name.get("openarmature.invocation")
447+
assert inv is not None
448+
assert inv.status.status_code == StatusCode.ERROR, (
449+
f"invocation span MUST end ERROR when a child errors; got {inv.status.status_code}"
450+
)
451+
452+
377453
# ---------------------------------------------------------------------------
378454
# Fixture 007 — retry attempt spans
379455
# ---------------------------------------------------------------------------

tests/test_smoke.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33

44
def test_package_versions() -> None:
55
assert openarmature.__version__ == "0.4.0rc0"
6-
assert openarmature.__spec_version__ == "0.8.2"
6+
assert openarmature.__spec_version__ == "0.9.0"

0 commit comments

Comments
 (0)