Subgraphs
A Construct can contain other Constructs. When a Construct appears inside another Construct’s nodes list, it becomes a subgraph with its own isolated state. Internal nodes do not leak into the parent.
Why sub-constructs stay declarative
Section titled “Why sub-constructs stay declarative”Top-level pipelines use @node and construct_from_module for ergonomic, signature-driven wiring. Sub-constructs use the declarative Construct(input=X, output=Y, nodes=[...]) form instead.
The reason is isolation. A sub-construct is a typed I/O boundary: it takes one type in, produces one type out, and hides everything in between. The explicit input and output declarations define that contract. With @node, dependencies are inferred from parameter names — but a sub-construct’s internal nodes should not be visible to the parent’s namespace. The declarative form makes the boundary explicit and prevents internal node names from leaking into parent wiring.
Think of it like a function signature: the top-level pipeline is the function body (where @node infers the flow), and each sub-construct is a called function with a declared interface.
Declaring a sub-Construct
Section titled “Declaring a sub-Construct”A sub-Construct must declare both input and output to define what crosses the boundary:
from pydantic import BaseModelfrom neograph import Construct, Node
class Claims(BaseModel): items: list[str]
class ScoredClaims(BaseModel): scored: list[dict]
# Internal nodes use the declarative Node(...) formlookup = Node("lookup", mode="gather", input=Claims, output=LookupResults, model="reason", prompt="rw/lookup", tools=[Tool("search", budget=5)])verify = Node("verify", mode="produce", input=LookupResults, output=VerifiedClaims, model="fast", prompt="rw/verify")score = Node("score", mode="produce", input=VerifiedClaims, output=ScoredClaims, model="fast", prompt="rw/score")
# Sub-construct with I/O boundaryenrich = Construct( "enrich", input=Claims, output=ScoredClaims, nodes=[lookup, verify, score],)The input type tells the framework how to extract data from the parent state. At runtime, the framework scans the parent state for a field whose value is an instance of Claims and passes it into the sub-graph. The output type defines what comes back — the sub-graph’s result is stored in parent_state.enrich as a ScoredClaims instance.
Using a sub-Construct in a top-level pipeline
Section titled “Using a sub-Construct in a top-level pipeline”The top-level pipeline uses @node for its own nodes. The sub-construct slots in alongside them:
from neograph import node, construct_from_module, compile, runimport sys
@node(output=RawText, prompt='rw/extract', model='fast')def extract(topic: FromInput[str]) -> RawText: ...
@node(output=Claims, prompt='rw/decompose', model='reason')def decompose(extract: RawText) -> Claims: ...
# enrich is the sub-Construct defined above
@node(output=FinalReport, prompt='rw/report', model='fast')def report(enrich: ScoredClaims) -> FinalReport: ...
# construct_from_module discovers @node functions;# add the sub-construct manually to the node listpipeline = Construct( "ingestion", nodes=[extract, decompose, enrich, report],)
graph = compile(pipeline)result = run(graph, input={"node_id": "BR-042"})The compiled graph executes extract -> decompose -> enrich (subgraph) -> report in sequence. The enrich subgraph runs its three internal nodes (lookup -> verify -> score) in isolation, then surfaces the ScoredClaims result back to the parent.
State isolation
Section titled “State isolation”The parent state model will contain a field enrich: ScoredClaims | None, but it will not contain lookup, verify, or score fields. Those exist only inside the sub-graph’s state.
This means:
- The
reportnode can readenrich(theScoredClaimsoutput) but cannot seelookuporverifyresults. - If the sub-Construct has a node named
scoreand the parent also has a node namedscore, there is no conflict. They write to different state models. - The sub-graph’s internal framework fields (
neo_oracle_gen_id,neo_each_item, etc.) are confined to the sub-graph.
Nesting: Construct in Construct
Section titled “Nesting: Construct in Construct”Nesting composes. A sub-Construct can itself contain sub-Constructs:
# Level 2: inner sub-constructverify_block = Construct( "verify-block", input=LookupResults, output=VerifiedClaims, nodes=[cross_check, validate, consolidate],)
# Level 1: outer sub-construct containing the inner oneenrich = Construct( "enrich", input=Claims, output=ScoredClaims, nodes=[lookup, verify_block, score],)
# Top levelpipeline = Construct( "ingestion", nodes=[extract, decompose, enrich, report],)Each level gets its own isolated state. The inner verify-block is invisible to enrich’s siblings, and enrich’s internals are invisible to pipeline’s nodes.
Modifiers on Constructs
Section titled “Modifiers on Constructs”All three modifiers — Oracle, Each, Operator — work on Constructs. When applied, the entire sub-pipeline is treated as the modified unit.
Oracle on a Construct
Section titled “Oracle on a Construct”Run the entire sub-pipeline N times in parallel, then merge the outputs:
enrich = Construct( "enrich", input=Claims, output=ScoredClaims, nodes=[lookup, verify, score],) | Oracle(n=3, merge_fn="combine_scores")The compiler creates N copies of the sub-graph execution, each receiving the same parent state but running independently. The merge function receives a list of N ScoredClaims results.
Each on a Construct
Section titled “Each on a Construct”Run the entire sub-pipeline once per item in a collection:
review_cluster = Construct( "review-cluster", input=ClusterGroup, output=ClusterReview, nodes=[analyze, check, summarize],) | Each(over="clusters.groups", key="label")If clusters.groups contains 5 items, the sub-pipeline runs 5 times in parallel. The result is a dict:
result["review_cluster"] == { "auth": ClusterReview(...), "payments": ClusterReview(...), "search": ClusterReview(...), ...}Operator on a Construct
Section titled “Operator on a Construct”Pause after the entire sub-pipeline completes:
validation = Construct( "validation", input=Draft, output=ValidationResult, nodes=[lint, test, report],) | Operator(when="validation_failed")The interrupt check runs after all three internal nodes have completed, not after each one individually.
Compile-time validation
Section titled “Compile-time validation”The compiler enforces sub-Construct contracts:
- A sub-Construct without
inputraisesConstructErrorat compile time. - A sub-Construct without
outputraisesConstructErrorduring state model generation. - An Operator modifier anywhere in the graph without a
checkpointerpassed tocompile()raisesConstructError.
Documentation © 2025-2026 Constantine Mirin, mirin.pro. Licensed under CC BY-ND 4.0.