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
1.1 — Your first Filter
Section titled “1.1 — Your first Filter”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 testsf = Doubler()state = f.fit([1, 2, 3]) # no-op for stateless filtersresult = f.forward([1, 2, 3], state)
print(f"fit() returns: {state}")print(f"forward() returns: {result}")fit() returns: {}forward() returns: [2, 4, 6]1.2 — A trainable Filter (with state)
Section titled “1.2 — A trainable Filter (with state)”Most useful filters learn something from training data. The key rule:
fit()returns a dict with the learned stateforward()receives that state as an argument — it does not live onself
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]1.3 — Filters with parameters
Section titled “1.3 — Filters with parameters”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]1.4 — Composing filters in a Graph
Section titled “1.4 — Composing filters in a Graph”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.
gThe fluent DSL
Section titled “The fluent DSL”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) ← normalizer1.5 — Inspecting the compiled plan
Section titled “1.5 — Inspecting the compiled plan”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()normalizernormalizerplan como texto
Sequence: Execute(normalizer) Execute(doubler)
What’s next
Section titled “What’s next”- 02 — Caching and state: the persistent cache — crash recovery and cross-run reuse for free.
- 03 — Search and optimization:
Study, samplers, pruning, seeds.