Skip to content

01 — Filters and Graphs

This notebook introduces the two foundational concepts in Soma:

  • Filter: the basic computation unit, with two phases:
    • fit(x, y) — learn state from training data (returns a dict)
    • forward(x, state) — transform data using that learned state
  • Graph: the computational graph that connects filters. It is the only user-facing pipeline API — you add filters as nodes and Soma compiles and executes them (with caching, parallelism, streaming).

Prerequisites: install soma with cd soma-python && maturin develop

A Filter is a Python class that inherits from soma.Filter. The simplest filter just transforms data without learning anything (stateless).

from soma import Filter, Graph
class Doubler(Filter):
"""A stateless filter that doubles every value."""
_cache_version = "nb01-doubler-v1" # clases de notebook: fija la identidad
def forward(self, x, state):
return [v * 2 for v in x]
# Filters work standalone too — handy for unit tests
f = Doubler()
state = f.fit([1, 2, 3]) # no-op for stateless filters
result = f.forward([1, 2, 3], state)
print(f"fit() returns: {state}")
print(f"forward() returns: {result}")
fit() returns: {}
forward() returns: [2, 4, 6]

Most useful filters learn something from training data. The key rule:

  • fit() returns a dict with the learned state
  • forward() receives that state as an argument — it does not live on self

This separation is what makes caching possible: same config + same data → same state.

class Normalizer(Filter):
"""Learns mean and std from data, then normalizes."""
_cache_version = "nb01-normalizer-v1"
def fit(self, x, y=None):
mean = sum(x) / len(x)
std = (sum((v - mean) ** 2 for v in x) / len(x)) ** 0.5
return {"mean": mean, "std": std}
def forward(self, x, state):
if state["std"] == 0:
return [0.0] * len(x)
return [(v - state["mean"]) / state["std"] for v in x]
norm = Normalizer()
state = norm.fit([10.0, 20.0, 30.0])
print("state:", state)
print("train data normalized:", norm.forward([10.0, 20.0, 30.0], state))
print("NEW data, same state: ", norm.forward([15.0, 25.0], state))
state: {'mean': 20.0, 'std': 8.16496580927726}
train data normalized: [-1.224744871391589, 0.0, 1.224744871391589]
NEW data, same state: [-0.6123724356957945, 0.6123724356957945]

Constructor kwargs become parameters: they live on self, and they are part of the filter’s cache identity (change a param → new cache key). Attributes prefixed with _ are private and excluded from the identity.

class Power(Filter):
_cache_version = "nb01-power-v1"
def __init__(self, exponent=2, **kwargs):
super().__init__(exponent=exponent, **kwargs)
def forward(self, x, state):
return [v ** self.exponent for v in x]
print(Power(exponent=2).forward([1.0, 2.0, 3.0], {}))
print(Power(exponent=3).forward([1.0, 2.0, 3.0], {}))
[1.0, 4.0, 9.0]
[1.0, 8.0, 27.0]

Graph is how filters become a pipeline. g.node(filter) adds a node (auto-named from the class, or pass an explicit id) and g.edge(a, b) wires them. g.fit(x) trains every trainable filter in topological order; g.forward(x) transforms new data with the learned states.

For linear chains there is also a fluent form:

g = Graph.somatize(Normalizer() >> Doubler())
g = Graph()
g.node("normalizer", Normalizer())
g.node("doubler", Doubler())
g.edge("normalizer", "doubler") # nodes are unconnected until you wire them
g.fit([10.0, 20.0, 30.0, 40.0, 50.0])
out = g.forward([15.0, 25.0, 35.0])
print("normalized+doubled:", [round(v, 3) for v in out])
normalized+doubled: [-2.121, -0.707, 0.707]

Al evaluar un Graph en un notebook, Soma dibuja la arquitectura como un SVG autocontenido (sin JavaScript — se ve en cualquier visor). En terminal, print(g) sigue mostrando el árbol de texto.

g

normalizerdoubler

For chains and branches there is a LangChain-style DSL: >> chains, | forks, and Graph.somatize(...) materializes the topology into an executable graph — same result as node() + edge(), one line:

g2 = Graph.somatize(Normalizer() >> Doubler())
g2.fit([10.0, 20.0, 30.0, 40.0, 50.0])
print("same pipeline, fluent build:", [round(v, 3) for v in g2.forward([15.0, 25.0, 35.0])])
print(g2.to_text())
same pipeline, fluent build: [-2.121, -0.707, 0.707]
Graph (2 nodes, 1 edges)
├── normalizer (Normalizer)
└── doubler (Doubler) ← normalizer

g.compile() devuelve un CompileInfo: un dict normal (puedes indexar info["plan_text"], info["diagnostics"]…) que en el notebook se muestra solo — resumen del plan, diagnósticos por nivel y el plan de ejecución dibujado.

g.compile()
nodes
2
cached
0
parallel branches
0
warning normalizer
gradient flow interrupted at `normalizer` (Trainable). Gradients from upstream will not reach downstream filters through this node.
info normalizer
cache lookups are resolved at runtime per node (key = hash(config + state + input)); the compiled plan contains no Cached nodes
normalizerdoubler
plan como texto
Sequence:
  Execute(normalizer)
  Execute(doubler)
  • 02 — Caching and state: the persistent cache — crash recovery and cross-run reuse for free.
  • 03 — Search and optimization: Study, samplers, pruning, seeds.