somatize
soma: the re-derived twin of Soma, one use case at a time.
A node is anything with a forward. It takes what arrived along the edges and
returns what it produced — there is no wrapper around either:
from somatize import Graph, Node
class Clean(Node): def forward(self, x, ctx): return x.strip()
class Shout(Node): def forward(self, x, ctx): return x.upper()
g = Graph.somatize(Clean() >> Shout())g.forward(" hello ")Whatever a node takes to answer — a retry, a model, three rounds of something —
happens inside it. Graph() with node() and edge() is still there for
when the topology is built in a loop or comes from outside.
codec(kind, type, dump=..., load=...) says how something wrapped in Opaque is
written down, which is what lets a graph keep what it produces.
Store(directory) is that same place, opened by hand. A directory two machines
can both see is how a training run written down on one is read back on another:
store.keep("round/3", trainer.export())trainer.load(store.recall("round/3"))Classes
Section titled “Classes”Not constructed directly — handed to you.
A name, and what it points at.
Bound.digest
Section titled “Bound.digest”The digest of the bytes it points at.
Bound.meta
Section titled “Bound.meta”What was said beside it, in the order it was said.
Bound.name
Section titled “Bound.name”The name somebody chose.
Bound.when
Section titled “Bound.when”When it was bound, in seconds since the epoch.
Ctx(device=None)What a node knows beyond its input.
Ctx.device
Section titled “Ctx.device”Where this node was said to run — "cuda:0" — or None. Written the
way torch writes it, so it can be handed straight to .to().
Graph()A computation graph: nodes, edges and what each one executes.
Everything else — node, edge, plan and the topology queries — is
inherited from the Rust class.
Constructors
Graph.somatize
Section titled “Graph.somatize”Graph.somatize(topology: _dsl.Topology) -> GraphMaterializes an expression into an executable graph.
You think it, Soma somatizes it:
Graph.somatize(Source() >> (Left() | Right()) >> Mean())Methods
Graph.cache
Section titled “Graph.cache”Graph.cache(node_id, salt=None)Says this node’s output is worth keeping, with the salt that tells apart two runs the key cannot tell apart on its own.
Graph.cached
Section titled “Graph.cached”Graph.cached()Which nodes are kept, and under what salt.
Graph.declarations
Section titled “Graph.declarations”Graph.declarations()What each node was built with, digested, for those where it could be said.
Graph.declared_as
Section titled “Graph.declared_as”Graph.declared_as(node_id, declaration)Notes what this node was built with, digested. In the key, beside
the identity: Embed(512) and Embed(64) are one class and two answers.
Graph.devices
Section titled “Graph.devices”Graph.devices()Where each placed node runs, in declaration order.
Graph.edge
Section titled “Graph.edge”Graph.edge(source, target)Connects two nodes. Both have to exist already.
Graph.edges
Section titled “Graph.edges”Graph.edges()The edges as (source, target) pairs, in insertion order.
Graph.figure
Section titled “Graph.figure”Graph.figure(overlay: Overlay | None = None, inside: Inside | None = None) -> FigureThe graph drawn, as a plotly.graph_objects.Figure. Nothing is
executed to draw it. Needs the viz extra.
inside opens a node up — {node: [(path, what), ...]}, which
somatize.torch.architecture reads off the modules it holds. overlay
lays what happened over what was declared, {node: [flag, ...]}.
Graph.fingerprints
Section titled “Graph.fingerprints”Graph.fingerprints()Which version of the code each node was written against, for those where it was noted.
Graph.foreseen_json
Section titled “Graph.foreseen_json”Graph.foreseen_json(input=None, *, store=None)What each node’s output will be called, with nothing executed.
The pass forward makes before its first node, asked for on its own:
{"keys": {node: name}, "unneeded": [node, ...]}, where unneeded is
what would not have to run because something below it is already kept.
store= for the same reason forward takes one — without a keeper
nothing is named. A directory nothing was written to answers fine.
Two kinds of node are missing from keys and it is not an omission: a
.mapped() one, and anything under an input that cannot be written down.
Read the absence as cannot tell, never as did not change. Both parts
are ordered by id, because this crosses a process boundary.
Graph.forward
Section titled “Graph.forward”Graph.forward(input: Any | None = None, *, broker: Broker | None = None, store: Store | str | None = None, watching: Callable[[Fact], None] | list[Callable[[Fact], None]] | None = None, stamping: dict[str, str] | None = None) -> AnyExecutes the whole graph and returns what it produced.
broker=Broker.embedded({"w1": Worker.at(...)}) says who knows where
each host is. This method sends the nodes, not you.
store is a directory or a Store: with one, whatever was declared
.cached() is looked up before being computed and kept after.
watching is told what happened, as it happens:
g.forward(x, watching=print) # in a notebookg.forward(x, watching=Recorder(store)) # keptg.forward(x, watching=[Recorder(store), draw]) # bothA fact arrives as a dict with a fact key naming it — the same shape
it is written down as. What a worker saw comes back down the connection
that was already open.
Graph.freeze
Section titled “Graph.freeze”Graph.freeze(node_id, state=None)Says this node’s state does not change from here on, with the digest of
the state it is settled at if the caller knows how to hash weights. The
primitive .frozen() ends at, and it declares: making it true is
somatize.torch.freeze.
Graph.frozen
Section titled “Graph.frozen”Graph.frozen()Which nodes are settled, and at what state — None for one with none.
Graph.hosts
Section titled “Graph.hosts”Graph.hosts()Which host each node sent away runs on, in declaration order.
Graph.identities
Section titled “Graph.identities”Graph.identities()What implements each node, by name.
Graph.implementation
Section titled “Graph.implementation”Graph.implementation(node_id)The object you registered under node_id, or None.
Graph.leaves
Section titled “Graph.leaves”Graph.leaves()The nodes where it leaves.
Graph.mapped
Section titled “Graph.mapped”Graph.mapped(node_id)Says this node maps over the items of its input: a list in, a list as long out. What it buys is a cache with the grain of an item.
Graph.mapped_nodes
Section titled “Graph.mapped_nodes”Graph.mapped_nodes()Which nodes map over the items of their input, in declaration order. A
list and not a dict, since mapping carries nothing beside it — and
mapped_nodes rather than mapped, which is already the setter.
Graph.node
Section titled “Graph.node”Graph.node(*args: Any) -> strAdds a node and returns its id, noting what it was built with.
Here and not in _dsl, because a graph built by hand in a loop has the
same collision: Embed(512) and Embed(64) are one class and two
answers. What cannot be written down the same way in two processes is
passed over here and refused in _check_it_was_obeyed.
Graph.nodes
Section titled “Graph.nodes”Graph.nodes()The ids, in insertion order.
Graph.place
Section titled “Graph.place”Graph.place(node_id, device)Places a node on a device. The primitive the DSL’s .on() ends up
calling, and what you use when you only have the id.
Graph.place_at
Section titled “Graph.place_at”Graph.place_at(node_id, host)Sends a node to a host, by name: the other half of place, independent
of it. What the name resolves to is decided by whoever executes, in
forward(broker=…).
Graph.plan
Section titled “Graph.plan”Graph.plan()How this graph will be walked: the decided shape, already distributed.
With no host set, distribute changes nothing.
Graph.plan_json
Section titled “Graph.plan_json”Graph.plan_json()The same shape, as data: Plan’s own serde form, as JSON text.
Two methods because they answer to different readers: plan() is for a
person, and this one is for whoever draws it — parsing a Debug to find
out what runs beside what is how a renderer starts lying.
Graph.predecessors
Section titled “Graph.predecessors”Graph.predecessors(node_id)The nodes feeding into node_id.
Graph.provision
Section titled “Graph.provision”Graph.provision(broker: Broker | dict[str, Any] | None) -> NoneTells each host what it is going to need, before the first node runs.
forward calls it, and whoever runs a graph in pieces does not have
to either: a piece provisions the graph it is a piece of, entire. That is
why the method exists — a worker has one catalog, and half of one is
a different catalog, refused mid-session and swallowed in silence by a
worker that has not greeted yet.
A host that gets nothing is told nothing, and two hosts that turn out to be one place are told once, with the union of what they hold.
Graph.roots
Section titled “Graph.roots”Graph.roots()The nodes where execution enters.
Graph.successors
Section titled “Graph.successors”Graph.successors(node_id)The nodes node_id feeds into.
Graph.topological_sort
Section titled “Graph.topological_sort”Graph.topological_sort()The nodes in an order where each comes after its predecessors.
Graph.written_as
Section titled “Graph.written_as”Graph.written_as(node_id, fingerprint)Notes which version of the code this graph was written against.
Metadata: never in a key, compared on a hit and said on stderr if
it differs.
Node()What a graph node executes. forward has to be written or the class
cannot be instantiated.
Node.at
Section titled “Node.at”Node.at(host: str) -> PieceThe same piece, in another process. The innermost one wins, and
independently of .on(), so the two can be written in any order. A
host is a name: what it resolves to is said by whoever executes.
Node.cached
Section titled “Node.cached”Node.cached(salt: str | None = None) -> PieceThe same piece, worth keeping: what each of its nodes produces is looked up before being computed, and kept after.
Opt-in, because keeping costs — and a node without it does not break
the chain: its key is still computed and passed on. salt tells apart
two runs the key cannot. Not in the key: the device, nor the fingerprint.
Node.forward
Section titled “Node.forward”Node.forward(input: Any, ctx: Ctx) -> AnyRuns it: takes what arrived along the edges, returns what it made.
ctx carries device, which is where this node was told to run.
Node.frozen
Section titled “Node.frozen”Node.frozen() -> PieceThe same piece, settled: its state does not change while the graph
runs. Here it is declared; making it true is somatize.torch.freeze,
the same division as .on(), where the core says where and the node
moves itself.
Node.mapped
Section titled “Node.mapped”Node.mapped() -> PieceThe same piece, mapping over the items of its input: a list in, a list as long out, item for item.
What gives a cache the grain of an item — without it, one new document among a thousand makes all thousand miss. The node is handed only the items that are missing, so it still batches, and an item is named after itself rather than after its position.
Node.named
Section titled “Node.named”Node.named(node_id: str) -> DeclaredThe same node, with the id you say.
Node.on
Section titled “Node.on”Node.on(device: str) -> PieceThe same piece, placed on a device. The innermost one wins, so
(A().on("cuda:0") >> B()).on("cuda:1") leaves A on 0 and B on 1.
The name is validated when the graph is materialized, in Rust.
Opaque
Section titled “Opaque”Opaque(value)Marks a value so it crosses the graph untouched. The node that receives it sees it unwrapped, so it is only written on returning.
Opaque.value
Section titled “Opaque.value”The object as it is.
Recorder
Section titled “Recorder”Recorder(store, *, run=None, summarising=None)Writes down what happened, one record per forward.
r = Recorder(store) # or Recorder(store, run="tuesday")g.forward(x, watching=r)store.resolve(f"run/{r.run}/0") # what that forward didRecorder.run
Section titled “Recorder.run”What this run is called, which is the first half of every name it writes.
Store(where_)Something that keeps bytes by their content, and names that point at them.
A dyn Store and not a Local, since there are two — and which one this is
does not reach the rest of Python, which is what lets a study run over a
shared folder here and over S3 there without a line changing.
Constructors
Store.on_bucket
Section titled “Store.on_bucket”Store.on_bucket(endpoint, bucket, *, region='us-east-1', key=None, secret=None, hosted=False)The same store, on a bucket: S3, MinIO, R2 — for a cluster with no shared
directory. hosted=True puts the bucket in the host name, which recent
AWS wants; the default puts it in the path, which MinIO wants. Without
key/secret it reads AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
It talks to the endpoint before returning: one that takes a conditional write and writes anyway would hand every trial to every machine and never say so.
Methods
Store.bind
Section titled “Store.bind”Store.bind(name, digest, meta=None)Points a name at some bytes, with whatever you want to remember beside it. Binding the same name again replaces it.
Store.bound
Section titled “Store.bound”Store.bound()Everything bound here. A scan, and that is the point: the records are the truth and an index over them is something you can throw away.
Store.claim
Section titled “Store.claim”Store.claim(name, digest, meta=None)Points a name at some bytes only if nobody has, and says whether it
did. This is how work gets handed out — not resolve then bind, since
between the two somebody else does the same:
me = store.put(f"{socket.gethostname()}/{os.getpid()}".encode())if store.claim(f"round/{r}/client/{k}", me): ...Store.get
Section titled “Store.get”Store.get(digest)The bytes, or None if this store does not have them.
Store.keep
Section titled “Store.keep”Store.keep(name, what, meta=None)Keeps a value under a name — tensors and all, by the codecs. The two lines that make an export cross a machine:
store.keep("round/3", trainer.export())trainer.load(store.recall("round/3"))What reaches the directory is bytes, and it never learns any of it was a tensor. Something nobody registered a codec for is refused with its type.
Store.put
Section titled “Store.put”Store.put(bytes)Saves these bytes and gives back the digest that names them.
Saving the same bytes twice is saving them once: that is what content addressing is for, and it is why a round of federated training that changed nothing costs nothing.
Store.recall
Section titled “Store.recall”Store.recall(name)What is kept under that name, alive again, or None if nothing is.
Store.resolve
Section titled “Store.resolve”Store.resolve(name)What that name points at, or None.
Broker
Section titled “Broker”Broker(listing)Where the hosts of a graph are, and who resolves them. One deployment today — the one inside this process, which is what makes soma work with no platform and no head node:
g.forward(x, broker=Broker.embedded({"w1": Worker.at("node3:7000")}))The others speak the same protocol, so what changes for a client is a URL.
Constructors
Broker.embedded
Section titled “Broker.embedded”Broker.embedded(workers: dict[str, Worker]) -> BrokerA broker inside this process, knowing where these hosts are.
Methods
Broker.packing_for
Section titled “Broker.packing_for”Broker.packing_for(host: str) -> WorkerThe worker declared for this host.
Broker.provision
Section titled “Broker.provision”Broker.provision(host, kind, id, blob, runtime)Tells the host’s wire what to provision the far side with, before the first job. Staged: nothing is sent until somebody dispatches, and even then only if the far side asks for it.
Broker.token_for
Section titled “Broker.token_for”Broker.token_for(host: str) -> bytes | NoneWhat this host shares a wire with, or None if the broker does not
know it.
None rather than an exception because a graph may name a host nobody
listed: either the run reaches it or it does not, and whichever happens
says so with the slice in front of it.
Broker.wire_token
Section titled “Broker.wire_token”Broker.wire_token(host)Bytes that are equal for two hosts that share a wire, so whoever decides what to pack can group by them. Asking is eager, because what gets packed depends on which hosts are the same place; connecting is not.
Worker
Section titled “Worker”Worker(target: str | list[str], mode: str = 'project', send: Sequence[str] = ())Where a slice goes, and how to pack for it. It opens nothing.
A declaration and not a connection, which is the change a broker brings:
python -m somatize.worker --listen 0.0.0.0:7000 # on the other machineg.forward(x, broker=Broker.embedded({"w1": Worker.at("node3:7000")}))mode says what gets sent: "project" (default) sends names, versions and
state and the worker supplies the code from its clone; "network" sends the
code too, and send=["my_package"] makes your own modules travel inside it.
Because it declares rather than connects, a host that is not there fails
when it is needed rather than when it is named.
Constructors
Worker.at
Section titled “Worker.at”Worker.at(addr: str, mode: str = 'project', send: Sequence[str] = ()) -> WorkerA worker that is already standing somewhere.
Worker.generic
Section titled “Worker.generic”Worker.generic(mode: str = 'project', send: Sequence[str] = (), python: str | None = None) -> WorkerA child running python -m somatize.worker.
Worker.spawn
Section titled “Worker.spawn”Worker.spawn(argv: list[str], mode: str = 'project', send: Sequence[str] = ()) -> WorkerA worker to be started as a child process. For testing: while the client starts the process, there is no independent worker worth the name.
Methods
Worker.packed
Section titled “Worker.packed”Worker.packed(nodes: dict[str, Any]) -> tuple[str, str, bytes]These nodes as an artifact the way this worker wants them: its kind, its id, and its bytes.
Functions
Section titled “Functions”codec(kind, of_type, *, dump, load)Says how objects of a type are written down and read back.
dump(obj) -> bytes and load(bytes) -> obj. The kind is what gets
written beside the bytes, so it is what a store keeps forever: name it after
the type, not after the run.
codecs_registered
Section titled “codecs_registered”codecs_registered()What has a codec registered today, in the order they were registered.
Constants
Section titled “Constants”__version__—'1.0.1'