Skip to content

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

Not constructed directly — handed to you.

A name, and what it points at.

The digest of the bytes it points at.

What was said beside it, in the order it was said.

The name somebody chose.

When it was bound, in seconds since the epoch.

Ctx(device=None)

What a node knows beyond its input.

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(topology: _dsl.Topology) -> Graph

Materializes an expression into an executable graph.

You think it, Soma somatizes it:

Graph.somatize(Source() >> (Left() | Right()) >> Mean())

Methods

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

Which nodes are kept, and under what salt.

Graph.declarations()

What each node was built with, digested, for those where it could be said.

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

Where each placed node runs, in declaration order.

Graph.edge(source, target)

Connects two nodes. Both have to exist already.

Graph.edges()

The edges as (source, target) pairs, in insertion order.

Graph.figure(overlay: Overlay | None = None, inside: Inside | None = None) -> Figure

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

Which version of the code each node was written against, for those where it was noted.

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

Executes 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 notebook
g.forward(x, watching=Recorder(store)) # kept
g.forward(x, watching=[Recorder(store), draw]) # both

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

Which nodes are settled, and at what state — None for one with none.

Graph.hosts()

Which host each node sent away runs on, in declaration order.

Graph.identities()

What implements each node, by name.

Graph.implementation(node_id)

The object you registered under node_id, or None.

Graph.leaves()

The nodes where it leaves.

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

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(*args: Any) -> str

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

The ids, in insertion order.

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

How this graph will be walked: the decided shape, already distributed. With no host set, distribute changes nothing.

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

The nodes feeding into node_id.

Graph.provision(broker: Broker | dict[str, Any] | None) -> None

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

The nodes where execution enters.

Graph.successors(node_id)

The nodes node_id feeds into.

Graph.topological_sort()

The nodes in an order where each comes after its predecessors.

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(host: str) -> Piece

The 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(salt: str | None = None) -> Piece

The 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(input: Any, ctx: Ctx) -> Any

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

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

The 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(node_id: str) -> Declared

The same node, with the id you say.

Node.on(device: str) -> Piece

The 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(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.

The object as it is.

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 did

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

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

The bytes, or None if this store does not have them.

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

What is kept under that name, alive again, or None if nothing is.

Store.resolve(name)

What that name points at, or None.

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(workers: dict[str, Worker]) -> Broker

A broker inside this process, knowing where these hosts are.

Methods

Broker.packing_for(host: str) -> Worker

The worker declared for this host.

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(host: str) -> bytes | None

What 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(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(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:

Terminal window
python -m somatize.worker --listen 0.0.0.0:7000 # on the other machine
g.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(addr: str, mode: str = 'project', send: Sequence[str] = ()) -> Worker

A worker that is already standing somewhere.

Worker.generic(mode: str = 'project', send: Sequence[str] = (), python: str | None = None) -> Worker

A child running python -m somatize.worker.

Worker.spawn(argv: list[str], mode: str = 'project', send: Sequence[str] = ()) -> Worker

A 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(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.

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

What has a codec registered today, in the order they were registered.

  • __version__'1.0.1'