Skip to content

CU8 — A value that crosses without being converted

class Layer(Node, nn.Module):
def __init__(self, m):
nn.Module.__init__(self); self.m = m
def forward(self, x, ctx):
return Done(Opaque(self.m(x)))
g = Graph.somatize(Layer(l1) >> Layer(nn.ReLU()) >> Layer(l2))
y = g.forward(Opaque(x))
y.pow(2).sum().backward() # crosses all three nodes

Status: closed. 53 tests in Rust, 64 in Python.

Value is a conversion boundary, and some values do not survive being converted. The case that motivated it: a torch tensor mid-autograd-graph. Measured — round-tripping it through lists gives requires_grad = False, grad_fn = None. The gradient graph breaks.

One variant, and only one:

Opaque(Arc<dyn Any + Send + Sync>)

It is not a PyObject because the core does not depend on PyO3 and is not going to start. Arc<dyn Any + Send + Sync> lets the Python crate store a Py<PyAny> inside and retrieve it with downcast_ref, without the core knowing there is Python behind it.

What the variant means, and where everything else follows from: this value only exists in this process and in this run.

propertyconsequencecorrect?
not hashed by contentthe node is not memoizedyes — memoizing a tensor mid-autograd would be a bug
not serializedthat subgraph does not travel to another machineyes — that is why the original sends gradients over the wire, not the graph
compared only by identity (Arc::ptr_eq)two wrappers of the same object are distinctit is the only thing the core can assert

The boundaries of the future cache and of remote execution end up visible in the type rather than being a rule somebody has to remember.

Opaque(x) gets written. Making an unknown object turn opaque by itself was rejected: the honesty of “a set does not cross” would be lost, and a hole everything fits through becomes the default path — leaving the graph without cache, without schemas and without distribution all at once, with nobody noticing.

A registry of opaque types that somatize.torch would fill on import was also rejected: it adds mutable global state and a dependency on import order, to save one word.

The node that receives it sees it unwrapped, so it is only written on returning (and once at the graph’s input).

  • torch.compile does not fuse across nodes. Three nodes → 3 graphs and 2 breaks; the same thing without Rust → 1 graph, 0 breaks. It is correct (the backward pass gets through) but the node is the compilation unit. User mitigation, one line: torch.compile(my_module) inside the node.
  • No content cache on those edges. For training that is right; for inference it is a real loss. It is recovered by converting on purpose at the edge: Done(y.detach().tolist()).
  • Out of the schemas’ reach when they arrive: there is no dtype and no shape.
  • The GIL serializes dispatch per node; torch releases it during kernels.

soma-python/tests/test_pipeline_torch.py assembles a four-node pipeline — lemmatizer (no gradients) → encoder → bottleneck → LSTM classifier — and trains it: 12,571 parameters, the loss drops from 1.09 to 0.005 in 40 steps. It is in the tests in full because besides checking, it documents the pattern.

Three things it teaches, and they are not obvious:

  • The two regimes coexist. The lemmatizer returns text, which crosses converted; the three nodes with parameters return Opaque. The boundary falls by itself where the gradient graph begins, without declaring it.
  • The node holds the modules, it does not inherit from nn.Module. Inheriting registers the parameters on its own, but breaks calling the node as a module: our forward carries ctx and torch calls it without one (TypeError). Verified.
  • The training loop goes outside, and the line that collects the parameters by walking g.nodes() is exactly the pain a somatize.torch.parameters(g) would erase. It is in plain sight so the decision is made with the example in front of you.

somatize.torchmodule(), parameters(), the training loop — is left for when it is clear how it should work. The core provides the hole; whoever knows what goes in it is a library, and that separation is what let this be closed without deciding that. (It opens in CU11.)