Skip to content

Commit d53fefc

Browse files
graph: spec v0.2 explicit subgraph mapping (#4)
Implement spec v0.2 / proposal 0002: explicit input/output mapping for subgraph composition. Ships ExplicitMapping(inputs=..., outputs=...) as a built-in projection strategy and adds the mapping_references_undeclared_field compile error. ProjectionStrategy.validate is an optional duck-typed compile-time hook — declarative strategies (ExplicitMapping) expose it; imperative custom projections aren't forced to write a no-op. Bumps openarmature to 0.3.0 and pins the spec submodule to v0.2.0.
1 parent f3b2ab1 commit d53fefc

15 files changed

Lines changed: 394 additions & 41 deletions

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "openarmature"
7-
version = "0.2.0"
7+
version = "0.3.0"
88
description = "Workflow framework for LLM pipelines and tool-calling agents."
99
readme = "README.md"
1010
requires-python = ">=3.12"
@@ -20,7 +20,7 @@ Repository = "https://github.com/LunarCommand/openarmature-python"
2020
Specification = "https://github.com/LunarCommand/openarmature-spec"
2121

2222
[tool.openarmature]
23-
spec_version = "0.1.1"
23+
spec_version = "0.2.0"
2424

2525
[dependency-groups]
2626
dev = [

src/openarmature/__init__.py

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

3-
__version__ = "0.2.0"
4-
__spec_version__ = "0.1.1"
3+
__version__ = "0.3.0"
4+
__spec_version__ = "0.2.0"

src/openarmature/graph/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
DanglingEdge,
1616
EdgeException,
1717
GraphError,
18+
MappingReferencesUndeclaredField,
1819
MultipleOutgoingEdges,
1920
NoDeclaredEntry,
2021
NodeException,
@@ -25,7 +26,7 @@
2526
UnreachableNode,
2627
)
2728
from .nodes import FunctionNode, Node
28-
from .projection import FieldNameMatching, ProjectionStrategy
29+
from .projection import ExplicitMapping, FieldNameMatching, ProjectionStrategy
2930
from .reducers import Reducer, append, last_write_wins, merge
3031
from .state import State
3132
from .subgraph import SubgraphNode
@@ -39,10 +40,12 @@
3940
"DanglingEdge",
4041
"EdgeException",
4142
"EndSentinel",
43+
"ExplicitMapping",
4244
"FieldNameMatching",
4345
"FunctionNode",
4446
"GraphBuilder",
4547
"GraphError",
48+
"MappingReferencesUndeclaredField",
4649
"MultipleOutgoingEdges",
4750
"Node",
4851
"NodeException",

src/openarmature/graph/builder.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"""
1212

1313
from collections.abc import Awaitable, Callable, Mapping
14-
from typing import Any, Self
14+
from typing import Any, Self, cast
1515

1616
from .compiled import CompiledGraph
1717
from .edges import ConditionalEdge, EndSentinel, StaticEdge
@@ -88,30 +88,47 @@ def compile(self) -> CompiledGraph[StateT]:
8888
fname: resolve_reducer(declared) for fname, declared in per_field.items()
8989
}
9090

91-
# 2. NoDeclaredEntry.
91+
# 2. MappingReferencesUndeclaredField — declarative projection
92+
# strategies (e.g. `ExplicitMapping`) expose an optional
93+
# `validate(parent_cls, child_cls)` hook that we invoke here so
94+
# misconfigured mappings fail compile rather than at runtime.
95+
# The hook is duck-typed: strategies with nothing declarative to
96+
# check (the default `FieldNameMatching`, hand-written imperative
97+
# projections) simply omit `validate` and the engine skips it.
98+
# ChildT is erased once SubgraphNode is stored as Node[StateT];
99+
# the cast restores enough type info to access `compiled.state_cls`
100+
# without pyright flagging an unknown member type.
101+
for node in self._nodes.values():
102+
if isinstance(node, SubgraphNode):
103+
sub = cast(SubgraphNode[StateT, State], node)
104+
validate = getattr(sub.projection, "validate", None)
105+
if validate is not None:
106+
validate(self.state_cls, sub.compiled.state_cls)
107+
108+
# 3. NoDeclaredEntry.
92109
if self._entry is None:
93110
raise NoDeclaredEntry()
94111

95-
# 3. Entry must point to a declared node (treat as DanglingEdge).
112+
# 4. Entry must point to a declared node (treat as DanglingEdge).
96113
if self._entry not in self._nodes:
97114
raise DanglingEdge(source="<entry>", target=self._entry)
98115

99-
# 4. DanglingEdge — both endpoints of every edge must be declared.
116+
# 5. DanglingEdge — both endpoints of every edge must be declared.
100117
for edge in self._edges:
101118
if edge.source not in self._nodes:
102119
raise DanglingEdge(source=edge.source, target=edge.source)
103120
if isinstance(edge, StaticEdge) and isinstance(edge.target, str):
104121
if edge.target not in self._nodes:
105122
raise DanglingEdge(source=edge.source, target=edge.target)
106123

107-
# 5. MultipleOutgoingEdges + index by source for the reachability pass.
124+
# 6. MultipleOutgoingEdges + index by source for the reachability pass.
108125
edges_by_source: dict[str, StaticEdge | ConditionalEdge[StateT]] = {}
109126
for edge in self._edges:
110127
if edge.source in edges_by_source:
111128
raise MultipleOutgoingEdges(edge.source)
112129
edges_by_source[edge.source] = edge
113130

114-
# 6. UnreachableNode — BFS from entry. Conditional edges over-approximate
131+
# 7. UnreachableNode — BFS from entry. Conditional edges over-approximate
115132
# by reaching every declared node (we cannot statically know the fn's
116133
# range), which keeps the check sound (no false positives).
117134
reachable = self._reachable_nodes(edges_by_source)

src/openarmature/graph/errors.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,19 @@ def __init__(self, field_name: str) -> None:
6262
self.field_name = field_name
6363

6464

65+
class MappingReferencesUndeclaredField(CompileError):
66+
"""Per spec v0.2.0 §2: a subgraph-as-node `inputs` or `outputs` mapping
67+
names a field that is not declared in the relevant state schema."""
68+
69+
category = "mapping_references_undeclared_field"
70+
71+
def __init__(self, *, direction: str, side: str, field_name: str) -> None:
72+
super().__init__(f"subgraph {direction!r} mapping references undeclared {side} field {field_name!r}")
73+
self.direction = direction
74+
self.side = side
75+
self.field_name = field_name
76+
77+
6578
# ===== Runtime errors (spec §4) =====
6679

6780

src/openarmature/graph/nodes.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ class Node[StateT: State](Protocol):
2121
"""A unit of work in a compiled graph."""
2222

2323
@property
24-
def name(self) -> str: ...
24+
def name(self) -> str:
25+
"""The name this node was registered under in its containing graph."""
26+
raise NotImplementedError
2527

26-
async def run(self, state: StateT) -> Mapping[str, Any]: ...
28+
async def run(self, state: StateT) -> Mapping[str, Any]:
29+
"""Execute against `state` and return a partial update to be merged via reducers."""
30+
raise NotImplementedError
2731

2832

2933
@dataclass(frozen=True)
Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,80 @@
11
"""Subgraph projection strategies.
22
3-
Per spec v0.1.1 §2 Subgraph: the default is **no projection in** (a subgraph
3+
Per spec v0.2.0 §2 Subgraph: the default is **no projection in** (a subgraph
44
runs from its own schema's field defaults) and **field-name matching for
55
projection out** (subgraph fields whose names match parent fields are merged
66
back into the parent via the parent's reducers).
77
8-
`ProjectionStrategy` is exposed as a seam so proposal 0002 (explicit
9-
input/output mapping) can slot in without changes to the engine's compile or
10-
execute paths. Parameterized on the parent and child state types so
11-
consumer-authored projections get typed `project_in` / `project_out`
12-
signatures without `cast(...)` gymnastics.
8+
Spec v0.2.0 (proposal 0002) adds explicit input/output mapping: a
9+
subgraph-as-node MAY declare `inputs` (parent → subgraph, additive over the
10+
default of no-projection-in) and/or `outputs` (subgraph → parent, replacement
11+
for field-name matching). Implemented here as `ExplicitMapping`.
12+
13+
Strategies parameterize on parent and child state types so consumer-authored
14+
projections get typed `project_in` / `project_out` signatures without
15+
`cast(...)` gymnastics.
1316
"""
1417

1518
from collections.abc import Mapping
1619
from typing import Any, Protocol
1720

21+
from .errors import MappingReferencesUndeclaredField
1822
from .state import State
1923

2024

25+
def _field_name_match_projection[ChildT: State](
26+
subgraph_final_state: ChildT,
27+
parent_state: State,
28+
subgraph_state_cls: type[ChildT],
29+
) -> Mapping[str, Any]:
30+
"""Spec v0.2 §2 default projection-out: subgraph fields whose names
31+
match parent fields are merged back via the parent's reducers; non-
32+
matching subgraph fields are discarded.
33+
34+
Shared by `FieldNameMatching.project_out` (which always uses it) and
35+
`ExplicitMapping.project_out` (which falls back to it when `outputs`
36+
was not declared, per spec v0.2).
37+
"""
38+
parent_fields = set(type(parent_state).model_fields.keys())
39+
sub_fields = set(subgraph_state_cls.model_fields.keys())
40+
shared = parent_fields & sub_fields
41+
return {name: getattr(subgraph_final_state, name) for name in shared}
42+
43+
2144
class ProjectionStrategy[ParentT: State, ChildT: State](Protocol):
22-
"""Strategy for moving state across the parent ↔ subgraph boundary."""
45+
"""Strategy for moving state across the parent ↔ subgraph boundary.
46+
47+
Two required methods plus one optional hook:
2348
24-
def project_in(self, parent_state: ParentT, subgraph_state_cls: type[ChildT]) -> ChildT: ...
49+
- `project_in` and `project_out` are required: the engine calls them on
50+
every subgraph step.
51+
- `validate(parent_cls, subgraph_state_cls) -> None` is an *optional*
52+
compile-time validation hook. If a strategy defines it, the parent
53+
graph's `compile()` calls it once per `SubgraphNode`; the strategy
54+
may raise a `CompileError` subclass when its declarations don't
55+
match the supplied schemas. Declarative strategies like
56+
`ExplicitMapping` use this to catch field-name typos before any
57+
node runs. Imperative custom projections typically have nothing
58+
declarative to check and can simply omit the method — the engine
59+
uses duck typing (`getattr`) to find it.
60+
"""
61+
62+
def project_in(self, parent_state: ParentT, subgraph_state_cls: type[ChildT]) -> ChildT:
63+
"""Build the subgraph's initial state at the moment it begins."""
64+
raise NotImplementedError
2565

2666
def project_out(
2767
self,
2868
subgraph_final_state: ChildT,
2969
parent_state: ParentT,
3070
subgraph_state_cls: type[ChildT],
31-
) -> Mapping[str, Any]: ...
71+
) -> Mapping[str, Any]:
72+
"""Project the subgraph's final state back to the parent as a partial update."""
73+
raise NotImplementedError
3274

3375

3476
class FieldNameMatching[ParentT: State, ChildT: State]:
35-
"""Default projection per spec v0.1.1 §2 Subgraph.
77+
"""Default projection per spec v0.2.0 §2 Subgraph.
3678
3779
Parameterized for protocol conformance under generics. `ParentT` is not
3880
consumed (the default projection ignores parent state on the way in),
@@ -50,7 +92,83 @@ def project_out(
5092
parent_state: ParentT,
5193
subgraph_state_cls: type[ChildT],
5294
) -> Mapping[str, Any]:
53-
parent_fields = set(type(parent_state).model_fields.keys())
95+
return _field_name_match_projection(subgraph_final_state, parent_state, subgraph_state_cls)
96+
97+
98+
class ExplicitMapping[ParentT: State, ChildT: State]:
99+
"""Per spec v0.2.0 §2: explicit input/output mapping.
100+
101+
`inputs`: subgraph_field → parent_field. At entry, the named parent field's
102+
current value is copied into the named subgraph field. Subgraph fields not
103+
listed receive their schema-declared defaults — there is NO field-name
104+
fallback (additive over the spec's default no-projection-in).
105+
106+
`outputs`: parent_field → subgraph_field. At exit, the named subgraph
107+
field's value is merged into the named parent field via the parent's
108+
reducer. Subgraph fields not listed are discarded — `outputs` REPLACES
109+
field-name matching for projection-out.
110+
111+
The two directions are independent: pass either, both, or neither. The
112+
spec distinguishes "absent" (default applies) from "present but empty"
113+
(only for `outputs`, where the defaults differ); `outputs=None` means
114+
absent (fall back to field-name matching), `outputs={}` means present
115+
and empty (project nothing). For `inputs` the two defaults coincide
116+
(no-projection-in either way), so the distinction is only meaningful
117+
for `outputs`.
118+
"""
119+
120+
def __init__(
121+
self,
122+
*,
123+
inputs: Mapping[str, str] | None = None,
124+
outputs: Mapping[str, str] | None = None,
125+
) -> None:
126+
self.inputs: dict[str, str] = dict(inputs) if inputs is not None else {}
127+
# Preserve absence on outputs so project_out can fall back to
128+
# field-name matching when None.
129+
self.outputs: dict[str, str] | None = dict(outputs) if outputs is not None else None
130+
131+
def project_in(self, parent_state: ParentT, subgraph_state_cls: type[ChildT]) -> ChildT:
132+
kwargs: dict[str, Any] = {
133+
sub_field: getattr(parent_state, parent_field) for sub_field, parent_field in self.inputs.items()
134+
}
135+
return subgraph_state_cls(**kwargs)
136+
137+
def project_out(
138+
self,
139+
subgraph_final_state: ChildT,
140+
parent_state: ParentT,
141+
subgraph_state_cls: type[ChildT],
142+
) -> Mapping[str, Any]:
143+
if self.outputs is None:
144+
# Outputs absent → spec default of field-name matching applies.
145+
return _field_name_match_projection(subgraph_final_state, parent_state, subgraph_state_cls)
146+
return {
147+
parent_field: getattr(subgraph_final_state, sub_field)
148+
for parent_field, sub_field in self.outputs.items()
149+
}
150+
151+
def validate(self, parent_cls: type[ParentT], subgraph_state_cls: type[ChildT]) -> None:
152+
parent_fields = set(parent_cls.model_fields.keys())
54153
sub_fields = set(subgraph_state_cls.model_fields.keys())
55-
shared = parent_fields & sub_fields
56-
return {name: getattr(subgraph_final_state, name) for name in shared}
154+
155+
for sub_field, parent_field in self.inputs.items():
156+
if sub_field not in sub_fields:
157+
raise MappingReferencesUndeclaredField(
158+
direction="inputs", side="subgraph", field_name=sub_field
159+
)
160+
if parent_field not in parent_fields:
161+
raise MappingReferencesUndeclaredField(
162+
direction="inputs", side="parent", field_name=parent_field
163+
)
164+
165+
if self.outputs is not None:
166+
for parent_field, sub_field in self.outputs.items():
167+
if parent_field not in parent_fields:
168+
raise MappingReferencesUndeclaredField(
169+
direction="outputs", side="parent", field_name=parent_field
170+
)
171+
if sub_field not in sub_fields:
172+
raise MappingReferencesUndeclaredField(
173+
direction="outputs", side="subgraph", field_name=sub_field
174+
)

src/openarmature/graph/subgraph.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Subgraphs as nodes.
22
3-
Per spec v0.1.1 §2 Subgraph: a compiled graph is used as a node inside another
3+
Per spec v0.2.0 §2 Subgraph: a compiled graph is used as a node inside another
44
graph. The subgraph runs against its own state schema; projection between
55
parent and subgraph is delegated to a `ProjectionStrategy` (default:
6-
`FieldNameMatching`).
6+
`FieldNameMatching`; spec v0.2.0 also defines `ExplicitMapping`).
77
88
Parameterized on both the parent's state type (`ParentT`) and the subgraph's
99
state type (`ChildT`). The outer graph only ever sees `run(state: ParentT)`

0 commit comments

Comments
 (0)