Filter Model
The Filter Trait
Section titled “The Filter Trait”A Filter is the fundamental unit of computation in Soma. Every filter has two phases:
fit(x, y)— Learn internal state from training dataforward(x, state)— Transform data using the learned state
This separation is key because each phase is independently cacheable and has different semantics.
/// The fundamental computation unit in Soma#[async_trait]pub trait Filter: Send + Sync { /// The learned state type (weights, statistics, etc.) type State: Serialize + Deserialize + Clone;
/// Learn state from training data fn fit(&self, x: &Tensor, y: Option<&Tensor>) -> Result<Self::State>;
/// Transform data using learned state (can be differentiable) fn forward(&self, x: &Tensor, state: &Self::State) -> Result<Tensor>;
/// Metadata for the compiler fn meta(&self) -> FilterMeta;}Filter Metadata
Section titled “Filter Metadata”Each filter declares its characteristics via FilterMeta. The compiler uses this for optimization, validation, and planning:
pub struct FilterMeta { pub kind: FilterKind, pub cacheable: bool, pub differentiable: bool, pub stream_mode: StreamMode,}
pub enum FilterKind { /// No state needed. forward() ignores state. /// Example: activation function, fixed projection Stateless,
/// Learns state in fit(), uses it in forward() /// Example: scaler, PCA, classifier Trainable,
/// Not differentiable. Breaks gradient flow. /// Example: decision tree, SQL query, file I/O Opaque,}Lifecycle
Section titled “Lifecycle”┌─────────────────────────────────────────────────────┐│ FILTER LIFECYCLE ││ ││ ┌─────────┐ ││ │ CREATE │ MyScaler { scale: 2.0 } ││ └────┬────┘ ││ │ ││ ▼ ││ ┌─────────┐ ┌─────────────┐ ││ │ FIT │────────►│ STATE │ ││ │ (x, y) │ │ { mean, std }│ ││ └─────────┘ └──────┬──────┘ ││ │ │ ││ │ cacheable: │ cacheable: ││ │ hash(config │ hash(config ││ │ + data_xy) │ + state + data_x) ││ │ │ ││ ▼ ▼ ││ ┌──────────┐ ││ │ FORWARD │ ││ │ (x,state)│ ││ └──────────┘ ││ ││ forward = the transformation (differentiable ││ if the filter supports it) │└─────────────────────────────────────────────────────┘forward
Section titled “forward”forward(x, state): The transformation. If the filter is differentiable, this maintains the computational graph for backpropagation. TheGraphcallsforward()on each filter and detaches the result when gradients are not needed.
impl Graph { /// Inference: no gradient tracking, detaches results pub fn forward(&self, x: &Tensor) -> Result<Tensor> { let mut current = x.clone(); for (filter, state) in &self.fitted_filters { current = filter.forward(¤t, state)?.detach(); } Ok(current) }}Examples
Section titled “Examples”Stateless Filter (no training needed)
Section titled “Stateless Filter (no training needed)”#[derive(SomaFilter)]#[soma(kind = "Stateless", cacheable = true, differentiable = true)]struct ReLU;
impl Filter for ReLU { type State = (); // no state
fn fit(&self, _x: &Tensor, _y: Option<&Tensor>) -> Result<()> { Ok(()) // nothing to learn }
fn forward(&self, x: &Tensor, _state: &()) -> Result<Tensor> { Ok(x.maximum(&Tensor::zeros_like(x))) }}Trainable Filter
Section titled “Trainable Filter”#[derive(SomaFilter)]#[soma(kind = "Trainable", cacheable = true, differentiable = true)]struct StandardScaler { #[soma(search(choices = [true, false]))] with_mean: bool,
#[soma(search(choices = [true, false]))] with_std: bool,}
#[derive(Serialize, Deserialize, Clone)]struct ScalerState { mean: Tensor, std: Tensor,}
impl Filter for StandardScaler { type State = ScalerState;
fn fit(&self, x: &Tensor, _y: Option<&Tensor>) -> Result<ScalerState> { let mean = if self.with_mean { x.mean(0)? } else { Tensor::zeros(x.dim(1))? }; let std = if self.with_std { x.std(0)? } else { Tensor::ones(x.dim(1))? }; Ok(ScalerState { mean, std }) }
fn forward(&self, x: &Tensor, state: &ScalerState) -> Result<Tensor> { // Differentiable: gradients flow through subtraction and division Ok((&(x - &state.mean)?) / &state.std)?) }}Trainable Model with Intermediate Metrics
Section titled “Trainable Model with Intermediate Metrics”#[derive(SomaFilter)]#[soma(kind = "Trainable", cacheable = true, differentiable = true)]struct LinearClassifier { #[soma(search(low = 1e-5, high = 1e-1, scale = "log"))] lr: f64,
#[soma(search(low = 10, high = 200))] epochs: usize,}
#[derive(Serialize, Deserialize, Clone)]struct LinearState { weights: Tensor, bias: Tensor,}
impl Filter for LinearClassifier { type State = LinearState;
fn fit(&self, x: &Tensor, y: Option<&Tensor>) -> Result<LinearState> { let y = y.ok_or(SomaError::RequiresLabels)?; let mut w = Tensor::randn(&[x.dim(1), y.dim(1)])?; let mut b = Tensor::zeros(&[y.dim(1)])?;
for epoch in 0..self.epochs { let pred = (x.matmul(&w)? + &b)?; let loss = cross_entropy(&pred, y)?; loss.backward()?;
// Update weights (inside fit, gradients are internal) w = (&w - &(self.lr * w.grad())?)?; b = (&b - &(self.lr * b.grad())?)?;
// Report metric for Study pruning ctx.report_metric("loss", loss.item(), epoch)?; }
Ok(LinearState { weights: w.detach(), bias: b.detach(), }) }
fn forward(&self, x: &Tensor, state: &LinearState) -> Result<Tensor> { // Differentiable: if someone backprops from here, // gradients flow through matmul to x Ok((x.matmul(&state.weights)? + &state.bias)?) }}Opaque Filter (non-differentiable)
Section titled “Opaque Filter (non-differentiable)”#[derive(SomaFilter)]#[soma(kind = "Opaque", cacheable = true)]struct DecisionTree { #[soma(search(low = 2, high = 50))] max_depth: usize,}
// State = the trained tree structure#[derive(Serialize, Deserialize, Clone)]struct TreeState { /* internal tree nodes */ }
impl Filter for DecisionTree { type State = TreeState;
fn fit(&self, x: &Tensor, y: Option<&Tensor>) -> Result<TreeState> { // Train decision tree (not differentiable) Ok(build_tree(x, y, self.max_depth)) }
fn forward(&self, x: &Tensor, state: &TreeState) -> Result<Tensor> { // Lookup in tree (not differentiable -- breaks gradient flow) Ok(predict_tree(state, x)) }}The Derive Macro
Section titled “The Derive Macro”#[derive(SomaFilter)] generates:
Searchableimpl: Collects#[soma(search)]annotations into aSearchSpaceSerialize/Deserialize: For remote executionconfig_hash(): SHA hash of all public fields (for cache key computation)from_sample(): Construct instance from sampled hyperparameterscurrent_params(): Extract current parameters as key-value pairs
Cache Key Hash Rules
Section titled “Cache Key Hash Rules”- Only public fields that are constructor parameters contribute to the hash
- Fields marked
#[soma(skip_hash)]are excluded - The
Statetype is hashed separately (it depends on training data) - The hash is deterministic: same config always produces the same key
#[derive(SomaFilter)]struct MyFilter { scale: f64, // ✓ included in config_hash method: String, // ✓ included in config_hash
#[soma(skip_hash)] verbose: bool, // ✗ excluded from config_hash}Python API
Section titled “Python API”from soma import Filter, Tensor, search
class MyScaler(Filter): with_mean: bool = search(choices=[True, False]) with_std: bool = True # not searchable, fixed
def fit(self, x: Tensor, y: Tensor = None): mean = x.mean(0) if self.with_mean else Tensor.zeros(x.shape[1]) std = x.std(0) if self.with_std else Tensor.ones(x.shape[1]) return {"mean": mean, "std": std}
def forward(self, x: Tensor, state): return (x - state["mean"]) / state["std"]The Python Filter base class uses metaclasses to:
- Register
search()descriptors asSearchDimensionentries - Generate
config_hash()from__init__parameters - Serialize the filter for remote execution via pickle + config JSON
The other kind of node: Step
Section titled “The other kind of node: Step”A Filter is a function: same config, same state, same input, same
output — which is what makes content-addressed caching sound. A
Step is not a function. It calls models, reads
the world, decides what to run next, and may pause for a person. Forcing
that into forward() would either make the trait async or make caching
lie, so it gets its own shape: poll() advances one turn and returns a
Transition describing what it needs.
The two are peer node kinds in the same graph, registered in the same
NodeCatalog and executed through the same single site (run_node).
What the machinery around them needs to know collapses into one metadata
type, NodeMeta (soma-core/src/node.rs): input resolution, panic
containment, the start/complete/fail events and the cache guard all read
NodeMeta, not “which kind is this”. From<StepMeta> sets
cacheable: false, deterministic: false, so the difference survives as
data the executor’s existing guard reads — there is no if is_step
code path.
The one thing a step never does is output-cache: its output is not a function of its input, the model is on the other end of it. Its effects are journaled instead — recorded once, replayed on resume — which is the durability mechanism that actually fits an effectful node. See Caching for the keying.