Skip to content

02 โ€“ Graph Execution

A DAG lets states fan out (one node, many children) and merge (many nodes, one child). Both are plain State flow โ€” the engine handles the plumbing.


๐ŸŽฏ Goal

Resolve an entity candidate into a name through two alternative resolvers, then tag the result โ€” while a Direct path bypasses resolution entirely.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”      โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ entity.extract  โ”‚ โ”€โ”€โ”ฌโ”€โ”€โ–ถ resolve_amzn โ”€โ”€โ”
โ”‚                 โ”‚   โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚                     โ”œโ”€โ”€โ–ถ entity.tag
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ entity.direct   โ”‚ โ”€โ”€โ”ผโ”€โ”€โ–ถ entity.direct  โ”€โ”ค (no children โ†’ terminal)
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚        โ”‚              
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ entity.kill     โ”‚ โ”€โ”€โ”˜  (yields nothing โ†’ branch pruned)
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

๐Ÿงฑ Nodes

from dagpipe import Graph, Node

class ExtractCandidate(Node):
    id = "entity.extract"

    def resolve(self, state):
        # fan-out: two candidate states for one input
        yield self.fork(state, payload_update={"entity.candidate": "amzn"})
        yield self.fork(state, payload_update={"entity.candidate": "flipkart"})

class ResolveAmazon(Node):
    id = "entity.resolve_amzn"

    def resolve(self, state):
        if state.get("entity.candidate") == "amzn":
            yield self.fork(state, payload_update={"entity.name": "Amazon"})

class ResolveFlipkart(Node):
    id = "entity.resolve_fk"

    def resolve(self, state):
        if state.get("entity.candidate") == "flipkart":
            yield self.fork(state, payload_update={"entity.name": "Flipkart"})

class TagNode(Node):
    id = "entity.tag"

    def resolve(self, state):
        # only runs when a name was resolved
        if state.get("entity.name"):
            tags = state.get("tags") or []
            yield self.fork(state, payload_update={"tags": tags + ["resolved"]})

class DirectNode(Node):
    id = "entity.direct"

    def resolve(self, state):
        yield self.fork(state, payload_update={"entity.name": "Direct"})

class KillNode(Node):
    id = "entity.kill"

    def resolve(self, state):
        return ()   # branch pruned: no states, no children visited

๐Ÿ—๏ธ Wire the graph

1
2
3
4
5
6
7
graph = Graph()
graph.add_edge(ExtractCandidate(), ResolveAmazon())
graph.add_edge(ExtractCandidate(), ResolveFlipkart())
graph.add_edge(ResolveAmazon(), TagNode())
graph.add_edge(ResolveFlipkart(), TagNode())
graph.add_root(DirectNode())
graph.add_root(KillNode())

๐Ÿƒ Run it

from dagpipe import Engine, Payload, Schema, State

class ItemState(State):
    schema = Schema({
        "raw": object,
        "entity": Schema({"candidate": str | None, "name": str | None}),
        "tags": list | None,
    })

root = ItemState(payload=Payload({"raw": object(), "tags": []}))
results = Engine(graph).run(root)

names = sorted(s.get("entity.name") for s in results)
assert names == ["Amazon", "Direct", "Flipkart"]

tagged = {s.get("entity.name"): s.get("tags") for s in results}
assert tagged["Amazon"] == ["resolved"]
assert tagged["Flipkart"] == ["resolved"]
assert tagged["Direct"] is None     # direct path never reached tag

๐Ÿ” What just happened

  • The engine enqueued all roots with the same root State.
  • ExtractCandidate forked two states โ†’ each resolver ran once per branch.
  • ResolveAmazon/ResolveFlipkart each produced at most one state (the wrong candidate branch pruned itself by yielding nothing).
  • TagNode merged both successful branches and appended "resolved".
  • DirectNode had no children โ†’ its state is terminal immediately.
  • KillNode yielded nothing โ†’ pruned, never contributes.

๐Ÿ’ก Tips

  • Root states are shared. Every root receives the same initial State โ€” they don't interact.
  • Merge = shared child. Both resolvers point at TagNode; the tag step runs once per incoming state.
  • Pruning happens naturally: yield nothing and the branch dies.
  • Cycle detection is automatic โ€” ValueError on a self/indirect cycle at add_edge time.