State model¶
Every op has a typed input and output dict. Edges between ops are wired by reference, not by string lookup at runtime.
Cell layout¶
State lives in a triple-keyed cell map: (op_full_name, var_name, context_id) → Value.
The context tuple is how parent ↔ child boundaries stay correct in
nested @graphs and how generator-op iterations stay isolated:
flowchart LR
subgraph PARENT["parent: GraphOp 'main' (context = ())"]
P_score["('main', 'score', ())"]
P_size["('main', 'size', ())"]
end
subgraph CHILD["child: nested @graph 'verify' (context = ('verify',))"]
C_grade["('verify.cls', 'grade', ('verify',))"]
C_score["('verify.cls', 'score', ('verify',))"]
C_out["('verify.work', 'trace', ('verify',))"]
end
P_score -->|"verify(score=PARENT['score'])"| C_grade
P_size -->|"verify(size=PARENT['size'])"| C_score
C_out -->|"work['trace'] >> PARENT['trace']"| P_size
classDef parent fill:#ede7f6,stroke:#5e35b1,color:#311b92
classDef child fill:#e0f2f1,stroke:#00897b,color:#004d40
class P_score,P_size parent
class C_grade,C_score,C_out child
Three rules fall out of this layout:
PARENT["k"]reads from the enclosing context. From the child's point of view,PARENTis the immediate parent — the runtime walks the context tuple upward until it findsk.op["k"]reads from a sibling at the same context. No walk — same context, different op name.op["src"] >> PARENT["dst"]writes upward. The scheduler emits the frame to both the child's own state and the parent's slot.
PARENT vs op["key"]¶
Use op["key"] to pass data between sibling ops. Use PARENT["key"]
only for external inputs (from engine.run() or from the parent graph
in nested contexts).
# CORRECT — read from sibling op's output
g = greet(name=PARENT["name"]) # PARENT["name"] = external input
u = upper(text=g["greeting"]) # g["greeting"] = sibling op output
START >> g >> u >> END
# WRONG — PARENT["greeting"] doesn't exist; g didn't forward there
u = upper(text=PARENT["greeting"]) # greeting is in g's state, not parent
| Reference | Source |
|---|---|
PARENT["k"] |
External inputs from engine.run(inputs={...}) or the parent graph |
op["k"] |
Output from a sibling op within the same graph |
>> END |
Auto-forwards the last op's outputs to the graph result |
Output mapping¶
Two equivalent styles. Pick whichever reads clearer:
# Style 1 — outputs= parameter (inline with op creation)
llm = LLMOp.of(
resource="gpt-4o",
messages=p["messages"],
outputs={"content": PARENT["answer"]},
)
# Style 2 — >> operator (standalone, equivalent)
llm = LLMOp.of(resource="gpt-4o", messages=p["messages"])
llm["content"] >> PARENT["answer"]
# Wildcard — forward all outputs
step = process(x=PARENT["x"], outputs={"*": PARENT})
The >> form is common inside loops where you want to update loop state
or forward the loop's final result.
Schema¶
Every op declares a schema based on its function signature and return annotation. The graph builder uses these schemas to:
- Validate that
op["k"]references an output the source op actually produces. - Validate that
PARENT["k"]references an input the engine will be given. - Pre-compute the runtime mapping so frame propagation is O(1).
If your op returns dynamic keys, declare them with outputs=[...] on the
op decorator or constructor — runtime cannot infer them otherwise.
Frames¶
Each op produces one or more frames during execution. A frame is a
snapshot of the op's output at one point in time. Most ops produce a
single frame; generator ops produce one per yield. The scheduler emits
frames downstream as they appear, which is what enables streaming
(see Streaming).
State within a graph is per-frame. When a generator op yields three values, downstream ops see three independent state slices, run in parallel by default.