01 — Declaring a graph
A graph says what exists. Nothing here runs anything, and the figure at the end is drawn from a graph that has never executed: printing a declaration is not observing it.
Five orthogonal facts, and confusing them is the easy mistake:
| says | |
|---|---|
Graph | what exists |
Catalog | who executes it |
Placement | where |
Plan | when |
Memory | what is remembered of each node |
from somatize import Graph, NodeA node is one single thing
Section titled “A node is one single thing”forward(input, ctx) takes what arrived along the edges and returns what it
produced. There is no “filter” type and no “step” type. Whatever a node needs
to answer — a retry, a model, three rounds of something — happens inside
it, holding whatever client that takes.
What crosses an edge is a closed set of shapes — numbers, text, lists,
maps, and an Opaque for what only exists in this process. A bool is not one
of them, and the refusal is on purpose: converting it to 1.0 would be the
library deciding what you meant.
class Tokenize(Node): """Words to numbers. No torch here on purpose: a node is a function."""
def forward(self, text, ctx): return [float(len(word)) for word in text.split()]
class Embed(Node): def __init__(self, scale): self.scale = scale
def forward(self, counts, ctx): return [n * self.scale for n in counts]
class Classify(Node): def __init__(self, threshold): self.threshold = threshold
def forward(self, values, ctx): # `1.0` and not `True`: what crosses an edge is a closed set of shapes, # and a `bool` is not one of them. The refusal is deliberate — turning it # into `1.0` behind your back is the library deciding what you meant. return 1.0 if sum(values) / len(values) > self.threshold else 0.0
class Vote(Node): """An aggregator is a node that reads a map. There is no type behind it."""
def forward(self, said, ctx): return sum(said.values()) / len(said)The DSL is the normal way to write one
Section titled “The DSL is the normal way to write one”>> is feeds, | is at the same time. .named(...) gives a node an id;
without one it gets the snake_case of its class.
g = Graph.somatize( Tokenize().named("tokenize") >> Embed(0.5).named("embed") >> (Classify(1.0).named("strict") | Classify(0.2).named("loose")) >> Vote().named("vote"))g.forward("a graph declared and then run")1.0The decisions hang off the declaration
Section titled “The decisions hang off the declaration”Each one is a different fact and none of them changes what the node does:
.on("cuda:0")— where inside a machine. It reaches the node as information inctx.device; the core cannot move anything to a GPU, so the one that obeys is the node..at("worker1")— which machine. A name, not an address: the same graph spreads over two processes here or two machines there without touching the declaration..cached()— worth keeping, so it is not computed twice..frozen()— settled, so what is kept stays valid..mapped()— maps over the items of its input, named one key per item.
spread = Graph.somatize( Tokenize().named("tokenize").at("worker1").mapped() >> Embed(0.5).named("embed").on("cuda:0").cached().frozen() >> (Classify(1.0).named("strict") | Classify(0.2).named("loose").at("worker2")) >> Vote().named("vote"))print("where each node runs :", spread.hosts())print("on which device :", spread.devices())print("what is kept :", spread.cached())print("what is settled :", spread.frozen())print("what maps :", spread.mapped_nodes())print("who implements what :", spread.identities())where each node runs : {'tokenize': 'worker1', 'loose': 'worker2'}on which device : {'embed': 'cuda:0'}what is kept : {'embed': None}what is settled : {'embed': None}what maps : ['tokenize']who implements what : {'tokenize': 'Tokenize', 'embed': 'Embed', 'strict': 'Classify', 'loose': 'Classify', 'vote': 'Vote'}The plan is when, and it is what gets drawn
Section titled “The plan is when, and it is what gets drawn”compile turns the graph into a plan and distribute cuts it where the hosts
change. A Wave is what runs at once; a Remote is what leaves the machine.
print(spread.plan())Sequence([Remote { host: Host("worker1"), inner: Execute { node: NodeId("tokenize"), from: [] } }, Execute { node: NodeId("embed"), from: [NodeId("tokenize")] }, Wave([Execute { node: NodeId("strict"), from: [NodeId("embed")] }, Remote { host: Host("worker2"), inner: Execute { node: NodeId("loose"), from: [NodeId("embed")] } }]), Execute { node: NodeId("vote"), from: [NodeId("strict"), NodeId("loose")] }])In a notebook the graph draws itself — the figure is the plan, because that is where the decisions show. The fill says only where a node runs; cached, frozen and mapped are badges in the label, since three facts do not fit in one colour and inventing a precedence between them would hide two of the three.
spread
The boxes say when, the arrows say what feeds what
Section titled “The boxes say when, the arrows say what feeds what”For a graph built with >> and | the two agree. They stop agreeing for a
graph that is not series-parallel — only reachable through node()/edge()
— where the decomposition falls back to a flat sequence and the nesting no
longer says who feeds whom. The N is the case: a→c, a→d, b→d.
The arrows are all there is there, and a figure without them would be a lie.
n = Graph()for who in ("a", "b", "c", "d"): n.node(who, Tokenize())n.edge("a", "c")n.edge("a", "d")n.edge("b", "d")n.figure()