Skip to content

Commit e8f5de8

Browse files
Merge pull request #1 from LunarCommand/engine/graph-foundation
graph foundation: state, compile/execute, conformance 001–010
2 parents b32e177 + 0dc808b commit e8f5de8

23 files changed

Lines changed: 1552 additions & 4 deletions

CLAUDE.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# CLAUDE.md
2+
3+
Orientation for Claude Code sessions in this repo. The `README.md` covers what the project is and how to use it; this file covers things that aren't obvious from reading the code.
4+
5+
## Spec is the source of truth
6+
7+
This repo is a Python implementation of [`openarmature-spec`](https://github.com/LunarCommand/openarmature-spec). Behavior is defined by the spec; this repo executes it.
8+
9+
- The spec lives at `openarmature-spec/` as a git submodule pinned to a released tag. Don't edit files in the submodule.
10+
- To bump the spec: `cd openarmature-spec && git checkout <tag>`, then bump the three places that track the spec version (see below).
11+
- Behavior changes that aren't already in the spec require a proposal in the spec repo first, not a PR here.
12+
13+
## Three places hold the spec version — keep them in sync
14+
15+
- `tool.openarmature.spec_version` in `pyproject.toml`
16+
- `__spec_version__` in `src/openarmature/__init__.py`
17+
- The submodule commit (must match a released tag, e.g. `v0.1.1`)
18+
19+
`tests/test_smoke.py` asserts the first two match. The third is enforced by convention.
20+
21+
## Test layout
22+
23+
- `tests/conformance/` — runs the spec's YAML fixtures against the engine via an adapter. Drives most of the behavior coverage.
24+
- `tests/unit/` — fills coverage gaps the conformance suite doesn't reach: `edge_exception`, `reducer_error`, `state_validation_error`, `SubgraphNode.run`, projection variants, frozen-state mutation, etc.
25+
- `tests/test_smoke.py` — version sync.
26+
27+
## Tooling
28+
29+
- `uv` for everything. Don't use `pip` directly.
30+
- Pyright **strict mode** is enforced (`pyproject.toml`). Annotations are not optional.
31+
- Ruff for lint + format. Pre-commit hook runs `ruff format` automatically — the file you committed may not be the file in the next diff.
32+
- `pytest-asyncio` with `asyncio_mode = "auto"``async def test_...` works with no decorator.
33+
34+
## Common commands
35+
36+
```bash
37+
uv run pytest -q # all tests
38+
uv run pytest tests/conformance/ -v # spec conformance only
39+
uv run ruff check . && uv run ruff format # lint + format
40+
uv run pyright src/ tests/ # type check
41+
```
42+
43+
## Engine design notes that are easy to miss
44+
45+
- `State` is `frozen=True` AND `extra="forbid"`. Nodes that return an undeclared field surface as a `state_validation_error`, not a silent drop.
46+
- Conditional edges over-approximate at compile time (a conditional from node X is treated as reaching every node), so the unreachable-node check is sound but not tight.
47+
- Each node has exactly one outgoing edge. Branching is via conditional edges, not multiple statics.
48+
- `END` is a distinct sentinel object, not a reserved string. Use the exported `END` constant.

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,62 @@
11
# openarmature-python
2+
3+
Python reference implementation of [OpenArmature](https://github.com/LunarCommand/openarmature-spec) — a workflow framework for LLM pipelines and tool-calling agents.
4+
5+
**Status:** alpha. The graph engine module is implemented against spec v0.1.1; the other modules in the charter are not yet built.
6+
7+
## Install
8+
9+
Not yet on PyPI. For local use, install from a checkout:
10+
11+
```bash
12+
uv add --editable /path/to/openarmature-python
13+
```
14+
15+
## Quick example
16+
17+
```python
18+
import asyncio
19+
from typing import Annotated
20+
21+
from pydantic import Field
22+
23+
from openarmature.graph import END, GraphBuilder, State, append
24+
25+
26+
class S(State):
27+
log: Annotated[list[str], append] = Field(default_factory=list)
28+
29+
30+
async def hello(_state: S) -> dict[str, list[str]]:
31+
return {"log": ["hello"]}
32+
33+
34+
async def world(_state: S) -> dict[str, list[str]]:
35+
return {"log": ["world"]}
36+
37+
38+
graph = (
39+
GraphBuilder(S)
40+
.add_node("hello", hello)
41+
.add_node("world", world)
42+
.add_edge("hello", "world")
43+
.add_edge("world", END)
44+
.set_entry("hello")
45+
.compile()
46+
)
47+
48+
final = asyncio.run(graph.invoke(S()))
49+
print(final.log) # ['hello', 'world']
50+
```
51+
52+
See `tests/conformance/` for fixtures covering conditional routing, subgraph composition, and the canonical compile- and runtime-error categories.
53+
54+
## Spec
55+
56+
The spec lives in [`openarmature-spec`](https://github.com/LunarCommand/openarmature-spec) and is pinned here as a git submodule. Conformance fixtures from the spec are exercised by `tests/conformance/`.
57+
58+
The pinned spec version is recorded in `tool.openarmature.spec_version` (in `pyproject.toml`) and exposed as `openarmature.__spec_version__`.
59+
60+
## License
61+
62+
Apache-2.0.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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.0"
23+
spec_version = "0.1.1"
2424

2525
[dependency-groups]
2626
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.1.0"
4-
__spec_version__ = "0.1.0"
4+
__spec_version__ = "0.1.1"

src/openarmature/graph/__init__.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Public API for the OpenArmature graph engine.
2+
3+
Re-exports the surface a user touches when building and running a graph: the
4+
state schema base, reducers, the builder/compiled pair, edge primitives and
5+
the END sentinel, the node/subgraph/projection seams, and the canonical
6+
compile-time and runtime error categories from spec §2 and §4.
7+
"""
8+
9+
from .builder import GraphBuilder
10+
from .compiled import CompiledGraph
11+
from .edges import END, ConditionalEdge, EndSentinel, StaticEdge
12+
from .errors import (
13+
CompileError,
14+
ConflictingReducers,
15+
DanglingEdge,
16+
EdgeException,
17+
GraphError,
18+
MultipleOutgoingEdges,
19+
NoDeclaredEntry,
20+
NodeException,
21+
ReducerError,
22+
RoutingError,
23+
RuntimeGraphError,
24+
StateValidationError,
25+
UnreachableNode,
26+
)
27+
from .nodes import FunctionNode, Node
28+
from .projection import FieldNameMatching, ProjectionStrategy
29+
from .reducers import Reducer, append, last_write_wins, merge
30+
from .state import State
31+
from .subgraph import SubgraphNode
32+
33+
__all__ = [
34+
"END",
35+
"CompileError",
36+
"CompiledGraph",
37+
"ConditionalEdge",
38+
"ConflictingReducers",
39+
"DanglingEdge",
40+
"EdgeException",
41+
"EndSentinel",
42+
"FieldNameMatching",
43+
"FunctionNode",
44+
"GraphBuilder",
45+
"GraphError",
46+
"MultipleOutgoingEdges",
47+
"Node",
48+
"NodeException",
49+
"NoDeclaredEntry",
50+
"ProjectionStrategy",
51+
"Reducer",
52+
"ReducerError",
53+
"RoutingError",
54+
"RuntimeGraphError",
55+
"State",
56+
"StateValidationError",
57+
"StaticEdge",
58+
"SubgraphNode",
59+
"UnreachableNode",
60+
"append",
61+
"last_write_wins",
62+
"merge",
63+
]

src/openarmature/graph/builder.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Graph builder: mutable construction → compile to immutable `CompiledGraph`.
2+
3+
Per spec §2: compilation MUST fail if the graph has no declared entry,
4+
unreachable nodes, dangling edges, a node with more than one outgoing edge,
5+
or a field with more than one declared reducer.
6+
"""
7+
8+
from collections.abc import Awaitable, Callable, Mapping
9+
from typing import Any, Self
10+
11+
from .compiled import CompiledGraph
12+
from .edges import ConditionalEdge, EndSentinel, StaticEdge
13+
from .errors import (
14+
ConflictingReducers,
15+
DanglingEdge,
16+
MultipleOutgoingEdges,
17+
NoDeclaredEntry,
18+
UnreachableNode,
19+
)
20+
from .nodes import FunctionNode, Node
21+
from .projection import FieldNameMatching, ProjectionStrategy
22+
from .reducers import Reducer
23+
from .state import State, field_reducers, resolve_reducer
24+
from .subgraph import SubgraphNode
25+
26+
27+
class GraphBuilder:
28+
"""Mutable builder for a graph; call `compile()` to produce a `CompiledGraph`."""
29+
30+
def __init__(self, state_cls: type[State]) -> None:
31+
self.state_cls = state_cls
32+
self._nodes: dict[str, Node] = {}
33+
self._edges: list[StaticEdge | ConditionalEdge] = []
34+
self._entry: str | None = None
35+
36+
def add_node(
37+
self,
38+
name: str,
39+
fn: Callable[[Any], Awaitable[Mapping[str, Any]]],
40+
) -> Self:
41+
if name in self._nodes:
42+
raise ValueError(f"node {name!r} already declared")
43+
self._nodes[name] = FunctionNode(name=name, fn=fn)
44+
return self
45+
46+
def add_subgraph(
47+
self,
48+
name: str,
49+
compiled: CompiledGraph,
50+
projection: ProjectionStrategy | None = None,
51+
) -> Self:
52+
if name in self._nodes:
53+
raise ValueError(f"node {name!r} already declared")
54+
proj: ProjectionStrategy = projection if projection is not None else FieldNameMatching()
55+
self._nodes[name] = SubgraphNode(name=name, compiled=compiled, projection=proj)
56+
return self
57+
58+
def add_edge(self, source: str, target: str | EndSentinel) -> Self:
59+
self._edges.append(StaticEdge(source=source, target=target))
60+
return self
61+
62+
def add_conditional(
63+
self,
64+
source: str,
65+
fn: Callable[[Any], str | EndSentinel],
66+
) -> Self:
67+
self._edges.append(ConditionalEdge(source=source, fn=fn))
68+
return self
69+
70+
def set_entry(self, name: str) -> Self:
71+
self._entry = name
72+
return self
73+
74+
def compile(self) -> CompiledGraph:
75+
# 1. ConflictingReducers — state schema check.
76+
per_field = field_reducers(self.state_cls)
77+
for fname, declared in per_field.items():
78+
if len(declared) > 1:
79+
raise ConflictingReducers(fname)
80+
resolved: dict[str, Reducer] = {
81+
fname: resolve_reducer(declared) for fname, declared in per_field.items()
82+
}
83+
84+
# 2. NoDeclaredEntry.
85+
if self._entry is None:
86+
raise NoDeclaredEntry()
87+
88+
# 3. Entry must point to a declared node (treat as DanglingEdge).
89+
if self._entry not in self._nodes:
90+
raise DanglingEdge(source="<entry>", target=self._entry)
91+
92+
# 4. DanglingEdge — both endpoints of every edge must be declared.
93+
for edge in self._edges:
94+
if edge.source not in self._nodes:
95+
raise DanglingEdge(source=edge.source, target=edge.source)
96+
if isinstance(edge, StaticEdge) and isinstance(edge.target, str):
97+
if edge.target not in self._nodes:
98+
raise DanglingEdge(source=edge.source, target=edge.target)
99+
100+
# 5. MultipleOutgoingEdges + index by source for the reachability pass.
101+
edges_by_source: dict[str, StaticEdge | ConditionalEdge] = {}
102+
for edge in self._edges:
103+
if edge.source in edges_by_source:
104+
raise MultipleOutgoingEdges(edge.source)
105+
edges_by_source[edge.source] = edge
106+
107+
# 6. UnreachableNode — BFS from entry. Conditional edges over-approximate
108+
# by reaching every declared node (we cannot statically know the fn's
109+
# range), which keeps the check sound (no false positives).
110+
reachable = self._reachable_nodes(edges_by_source)
111+
for node_name in self._nodes:
112+
if node_name not in reachable:
113+
raise UnreachableNode(node_name)
114+
115+
return CompiledGraph(
116+
state_cls=self.state_cls,
117+
entry=self._entry,
118+
nodes=dict(self._nodes),
119+
edges=edges_by_source,
120+
reducers=resolved,
121+
)
122+
123+
def _reachable_nodes(
124+
self,
125+
edges_by_source: Mapping[str, StaticEdge | ConditionalEdge],
126+
) -> set[str]:
127+
assert self._entry is not None
128+
reachable: set[str] = {self._entry}
129+
frontier = [self._entry]
130+
all_names = set(self._nodes.keys())
131+
while frontier:
132+
current = frontier.pop()
133+
edge = edges_by_source.get(current)
134+
if edge is None:
135+
continue
136+
if isinstance(edge, StaticEdge):
137+
if isinstance(edge.target, str) and edge.target not in reachable:
138+
reachable.add(edge.target)
139+
frontier.append(edge.target)
140+
else:
141+
for name in all_names - reachable:
142+
reachable.add(name)
143+
frontier.append(name)
144+
return reachable

0 commit comments

Comments
 (0)