Skip to content

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
Graphwhat exists
Catalogwho executes it
Placementwhere
Planwhen
Memorywhat is remembered of each node
from somatize import Graph, Node

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)

>> 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.0

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 in ctx.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

Figure from cell 11

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()

Figure from cell 13