Graph
A Graph defines a computation graph from nodes with automatic edge inference.
Automatic wiring - Edges inferred from matching output/input names
Build-time validation (
strict_types) - Type mismatches caught at construction whenstrict_types=TrueHierarchical composition - Graphs nest as nodes via
.as_node()Partial input semantics -
with_entrypoint()andselect()narrow inputs to the active subgraphImmutable -
bind(),select(),with_entrypoint(), and other methods return new instances
from hypergraph import node, Graph
@node(output_name="doubled")
def double(x: int) -> int:
return x * 2
@node(output_name="result")
def add_one(doubled: int) -> int:
return doubled + 1
# Edges inferred: double → add_one (via "doubled")
g = Graph([double, add_one])Edges are inferred automatically: if node A produces output "x" and node B has input "x", an edge A→B is created.
When multiple producers can satisfy the same consumer input p, implicit inference applies producer shadow-elimination:
Remove edge
u -> v (p)iff every valid execution path by whichpcould flow fromutovpasses through another producer ofpfirst.If elimination leaves no realizable producer for
(v, p), graph construction fails withGraphConfigErrorand asks you to add ordering, explicit edges, or rename channels.
Constructor
Graph(nodes, *, edges=None, entrypoint=None, name=None, strict_types=False, shared=None)
Graph(nodes, *, edges=None, entrypoint=None, name=None, strict_types=False, shared=None)Create a graph from nodes.
Args:
nodes(list[HyperNode]): List of nodes to include in the graphedges(list[tuple] | None): Explicit edge declarations. Whensharedis set, explicit edges are additive (ordering hints on top of auto-inferred edges). Otherwise, disables auto-inference when provided. See Explicit Edges below.entrypoint(str | list[str] | tuple[str, ...] | None): Convenience shortcut forwith_entrypoint(). Required for cyclic graphs. Accepts a node name or list/tuple of node names. See Entrypoints below.name(str | None): Optional graph name. Required if usingas_node()for composition.strict_types(bool): If True, validate type compatibility between connected nodes at construction time. Default: False.shared(list[str] | None): Parameter names that are shared state. Shared params are excluded from auto-wiring — multiple producers are allowed, and nodes read the latest value from run state. The user provides ordering viaedgesoremit/wait_for. Shared params are required atrun()time unless bound. See Shared State below.
Raises:
GraphConfigError- If duplicate node names existGraphConfigError- If multiple nodes produce the same output name (in auto-inference mode without ordering)GraphConfigError- Ifstrict_types=Trueand types are incompatible or missing
Properties
name: str | None
name: str | NoneThe graph's name. Used for identification and required for nesting via as_node().
strict_types: bool
strict_types: boolWhether type validation is enabled for this graph.
nodes: dict[str, HyperNode]
nodes: dict[str, HyperNode]Map of node name → node object. Returns a copy to prevent mutation.
nx_graph: nx.DiGraph
nx_graph: nx.DiGraphThe underlying NetworkX directed graph. Useful for advanced graph analysis.
inputs: InputSpec
inputs: InputSpecSpecification of graph input parameters. See InputSpec Reference for details.
outputs: tuple[str, ...]
outputs: tuple[str, ...]All output names produced by nodes in the graph.
leaf_outputs: tuple[str, ...]
leaf_outputs: tuple[str, ...]Outputs from terminal nodes (nodes with no downstream consumers).
selected: tuple[str, ...] | None
selected: tuple[str, ...] | NoneDefault output selection set via select(), or None if all outputs are returned.
entrypoints_config: tuple[str, ...] | None
entrypoints_config: tuple[str, ...] | NoneConfigured entry point node names set via with_entrypoint(), or None if all nodes are active.
has_cycles: bool
has_cycles: boolTrue if the graph contains cycles.
has_async_nodes: bool
has_async_nodes: boolTrue if any node in the graph is async.
has_interrupts: bool
has_interrupts: boolTrue if any node in the graph is an interrupt node.
interrupt_nodes: list[HyperNode]
interrupt_nodes: list[HyperNode]Ordered list of interrupt node instances in the graph.
definition_hash: str
definition_hash: strMerkle-tree style hash of graph structure. Used for cache invalidation.
The hash includes:
Node names and their definition hashes
Graph edges (data dependencies)
The hash excludes:
Bound values (runtime values, not structure)
Node order in constructor list (normalized by name)
Methods
describe(*, show_types=True) -> str
describe(*, show_types=True) -> strReturn a multiline summary of the active graph scope.
Use describe() when you want one scan-friendly answer to:
what inputs the graph expects
which values are already bound
which outputs the configured graph can produce
which nodes are active in the current scope
Unlike graph.outputs, the summary respects scope-defining configuration such as with_entrypoint() and select().
By default, type hints are included for both inputs and outputs when available.
When you slice the graph, the summary follows the active subgraph:
Hide type hints when you only want names:
bind(**values) -> Graph
bind(**values) -> GraphPre-fill input parameters with values. Returns a new Graph (immutable pattern).
Args:
**values: Named values to bind. Keys must be graph inputs in the current scope.
Returns: New Graph with bindings applied
Raises:
ValueError- If binding a name not recognized as a graph input in the current scope
Shared State and Non-Copyable Objects
Bound values are intentionally shared across runs, not deep-copied. This makes .bind() ideal for:
Stateful objects (database connections, vector stores, embedders)
Non-copyable objects (objects with thread locks, file handles, C extensions)
Dependency injection (providing shared resources to multiple nodes)
bind() is not an override mechanism for active internal graph outputs. If a value is still produced by an upstream node in the current graph scope, that producer wins at runtime. To provide a formerly-intermediate value yourself, first slice the graph so it becomes an input:
Why not use function defaults?
Function signature defaults are deep-copied per run to prevent mutable default mutation:
The error message explains why copying is needed for signature defaults and suggests using .bind() for shared state.
unbind(*names) -> Graph
unbind(*names) -> GraphRemove specific bindings. Returns a new Graph.
Args:
*names: Names to unbind
Returns: New Graph with specified bindings removed
select(*names) -> Graph
select(*names) -> GraphSet a default output selection. Returns a new Graph (immutable pattern).
This controls which outputs are returned by runner.run() and which outputs are exposed when the graph is used as a nested node via as_node().
select also narrows graph.inputs to only the parameters needed to produce the selected outputs. Nodes that don't contribute to those outputs are excluded from input computation.
select is scope-defining: it narrows what executes, what is required from the caller, and what is returned to the caller.
Runtime select= overrides are not supported. Use graph.select(...) to configure output scope before calling run().
Args:
*names: Output names to include. Must be valid graph outputs.
Returns: New Graph with default selection set
Raises:
ValueError- If any name is not ingraph.outputs
Nested graph behavior
When a graph with select is used as a nested node, only the selected outputs are visible to the parent graph. Unselected outputs cannot be wired to downstream nodes.
If the parent graph needs an intermediate output, add it to the selection:
with_entrypoint(*node_names) -> Graph
with_entrypoint(*node_names) -> GraphSet execution entry points. Returns a new Graph (immutable pattern, like bind() and select()).
Entry points define where execution starts. Upstream nodes are excluded from the active subgraph -- their outputs become direct user inputs instead of computed values.
embed is upstream of retrieve, so it is excluded from the active subgraph. Its output embedding is no longer produced by a node -- it becomes a required user input.
Args:
*node_names: One or more node names to use as entry points.
Returns: New Graph with entry points configured
Raises:
GraphConfigError- If any name is not a node in the graphGraphConfigError- If any name is a gate node (gates control routing, they cannot be entry points)
Chainable
with_entrypoint merges with previous calls. Each call adds to the set:
Cycles
Cyclic graphs must be configured with constructor entrypoint:
with_entrypoint(...) remains useful for DAG slicing and for adding additional entrypoints to an already configured graph.
Composes with select and bind
All four dimensions compose -- with_entrypoint (start), select (end), bind (pre-fill), and function defaults (fallback):
Active-set enforcement
with_entrypoint is not just input narrowing -- it prevents upstream nodes from executing at runtime. Even if you provide all of embed's inputs, it will not run:
as_node(*, name=None) -> GraphNode
as_node(*, name=None) -> GraphNodeWrap graph as a node for composition. Returns a new GraphNode.
Args:
name(str | None): Node name. If not provided, usesgraph.name.
Returns: GraphNode wrapping this graph
Raises:
ValueError- Ifnameis None andgraph.nameis None
See GraphNode section in Nodes Reference for more details.
Type Validation (strict_types)
When strict_types=True, the Graph validates type compatibility between connected nodes at construction time.
How It Works
For each edge (source_node → target_node):
Get the output type from source node
Get the input type from target node
Check if output type is compatible with input type
Raise
GraphConfigErrorif incompatible or missing
Compatible Types
Type Mismatch Error
Missing Annotation Error
Union Type Compatibility
A more specific type satisfies a broader union type:
When to Use strict_types
Development: Enable it to catch wiring mistakes early
Production: Enable it for safety in critical pipelines
Prototyping: Disable it (default) for quick experiments
GraphConfigError
Raised when graph configuration is invalid.
Common Causes
Duplicate node names:
Multiple nodes produce same output:
Three ways to resolve this:
emit/wait_for— Prove ordering between the producers. See Shared Outputs in a Cycle.Explicit edges — Declare the full topology manually via
edges=[...]. See Explicit Edges below.Mutually exclusive gate branches — Place producers in different branches of an
@ifelseor@routegate.
Invalid identifiers:
Inconsistent defaults:
Explicit Edges
By default, Graph infers edges from matching output/input names. Pass edges to disable auto-inference and declare the graph topology manually.
Both add_query and add_response produce messages. With auto-inference this would raise GraphConfigError. Explicit edges make the topology unambiguous.
Edge Format
Each edge is a tuple of (source, target) or (source, target, values):
(source, target)
Values auto-detected from intersection of source.outputs and target.inputs
(source, target, "name")
Explicit single value
(source, target, ["a", "b"])
Explicit multiple values
Source and target can be node objects or string names:
When a 2-tuple has no overlapping output/input names, it becomes an ordering-only edge — a structural dependency with no data flow.
Restrictions
add_nodes()raisesGraphConfigErroron a graph with explicit edges. Create a newGraphwith the complete node and edge lists instead.Auto-inference and explicit edges don't mix. When
edgesis provided, only declared edges exist.Gate control edges and
emit/wait_forordering edges are still auto-wired in explicit mode. Don't declare edges from gate nodes to their targets — the explicit edge would override the auto-wired control edge type, affecting visualization.
Shared State
When multiple nodes read and write the same value (e.g., messages in a chat loop), auto-wiring can't determine which producer feeds which consumer. Instead of requiring nested graphs or fully explicit edges, declare the param as shared:
How it works
No data edges are inferred for
messages— it's excluded from auto-wiringMultiple producers (
add_user_message,add_response) are allowed without conflictNon-shared params (
user_input,response) are still auto-wired normallyOrdering must be provided via
edgesoremit/wait_for— the explicit edges above become ordering-only edges (sincemessagesis shared, no data flows through them)Initial value is required at
run()time:runner.run(graph, {"messages": [], ...})
Connectivity validation
If auto-wiring with shared params leaves the graph disconnected, construction fails with a helpful error showing which node groups need connecting:
In the visualization
Shared params are omitted from the interactive input lane and from INPUT/INPUT_GROUP rendering, and are rendered as a %% shared state: messages comment in Mermaid output.
visualize(*, depth=0, theme="auto", show_types=False, separate_outputs=False, show_inputs=True, show_bounded_inputs=False, filepath=None)
visualize(*, depth=0, theme="auto", show_types=False, separate_outputs=False, show_inputs=True, show_bounded_inputs=False, filepath=None)Render an interactive visualization of the graph.
Args:
depth(int): How many levels of nested graphs to expand. Default: 0 (all collapsed).theme(str):"dark","light", or"auto"(detects from notebook environment). Default:"auto".show_types(bool): Display type annotations on nodes. Default: False.separate_outputs(bool): Render outputs as separate DATA nodes instead of direct edges. Default: False.show_inputs(bool): Show INPUT/INPUT_GROUP nodes. Default: True.show_bounded_inputs(bool): Include bound INPUT/INPUT_GROUP nodes whenshow_inputs=True. Ifshow_inputs=False, the input lane stays hidden. Default: False.filepath(str | None): Save to HTML file instead of displaying inline. Default: None.
Returns: ScrollablePipelineWidget if filepath=None, otherwise None (saves to file).
See Visualize Graphs for the full usage guide.
Complete Example
Last updated