There are three ways to express custom logic in tflo — and I kept picking the wrong one
Extension traits, closure-based primitives, and the Operator trait all solve different problems. Here's when each one fits, with runnable examples.
You want to compute something custom. The built-in operators get you 80% of the way, but the last 20% — the domain-specific formula, the detector your problem needs, the thing that makes your system different — isn’t in the library.
tflo gives you three ways to express that custom logic. I keep reaching for the wrong one. If you’ve ever written a full Operator implementation for a one-line formula, or built an extension trait for a stateful algorithm that has no business being stateless, you’ve done the same thing.
Here’s what I learned about when each path fits.
The three paths
Extension traits — add methods to Comp<R, f64> from your own crate using existing operations. Zero runtime overhead. Best for algorithms that compose existing operators.
Closure-based primitives — drop a closure inline in your .tflo() block. map_f64 for transforms, scan_f64 for state machines. Best for custom formulas you’d rather not compile into a separate struct.
Operator trait — implement the trait, attach the node. A reusable stateful node with its own eval/reset contract. Best for reusable nodes with complex internal state.
The table at the bottom of this post shows the full comparison. But first, let me show you when each one hurts — and when it sings.
Extension traits: when your algorithm is just a composition
Extension traits let you add methods to Comp<R, f64> from your own crate. They don’t create new runtime nodes — they wire up existing ones (sma, std, arithmetic, comparisons) into a composite sub-graph.
Here’s a normalized_score that z-scores a price series:
use tflo_core::prelude::*;
use tflo_ops::prelude::*;
trait CustomExt<R: 'static> {
fn normalized_score<W: Into<Window>>(&self, window: W) -> Comp<R, f64>;
}
impl<R: 'static> CustomExt<R> for Comp<R, f64> {
fn normalized_score<W: Into<Window>>(&self, window: W) -> Comp<R, f64> {
let w: Window = window.into();
let mean = self.sma(w);
let std = self.std(w);
(self - &mean) / &std
}
}
Then use it like any built-in:
let scores: Vec<f64> = ticks.into_iter()
.tflo(|t| {
t.timestamp(|x| x.ts);
let price = t.prop(|x| x.price);
price.normalized_score(14)
})
.collect();
Where this works
Extension traits shine when your algorithm is an arrangement of existing pieces. Spread ratio between two series? (a - b) / b. Mean band with upper/lower envelopes? sma, std, and arithmetic. Nothing new enters the graph — the compiler sees the same node types it already knows.
The performance is identical to writing the composite manually. Because it is the same composite — just hidden behind a method name.
Where this doesn’t work
Extension traits can’t introduce new behavior. If your formula involves a state machine, a time-decayed filter, or any logic that doesn’t exist as a built-in node, you can’t express it as a composition. You’ll find yourself contorting the problem to fit the available pieces, and the result will be wrong in subtle ways.
Don’t do that. Use closures instead.
Closure-based primitives: when you need inline custom logic
This is the path I reach for most now. Instead of defining a struct, implementing a trait, and registering a node, you write a closure directly in your .tflo() block.
tflo provides six closure-based primitives:
| Method | What it does | Closure shape |
|---|---|---|
map_f64 | Transform every value | |x| -> f64 |
map2_f64 | Transform from two inputs | (&other, |x, y| -> f64) |
filter_f64 | Keep or drop values | |x| -> bool |
filter_map_f64 | Transform + optionally drop | |x| -> Option<f64> |
scan_f64 | Stateful transform (one input) | (|| init, |state, x| -> f64) |
scan2_f64 | Stateful transform (two inputs) | (&other, || init, |state, x, y| -> f64) |
Stateless transforms with map_f64
You want to clamp negative prices to zero. You want to convert units. You want to apply a scaling factor. This is where map_f64 fits — it’s a pure function from input to output.
let clamped: Vec<f64> = ticks.into_iter()
.tflo(|t| {
t.timestamp(|x| x.ts);
let price = t.prop(|x| x.price);
price.map_f64(|x| x.max(0.0))
})
.collect();
That’s it. No struct, no trait impl, no registration. The closure captures nothing (in this case) and runs once per record.
You can also name the node for graph-inspection readability:
let scaled: Vec<f64> = ticks.into_iter()
.tflo(|t| {
t.timestamp(|x| x.ts);
let price = t.prop(|x| x.price);
price.map_f64(|x| x * 100.0).named("scaled_price")
})
.collect();
The name appears in graph plans and debug output. It has no semantic effect — but when you’re staring at a graph with six anonymous closures, names save time.
Filtering with filter_f64 and filter_map_f64
filter_f64 keeps values that satisfy a predicate and drops the rest:
let above_threshold: Vec<f64> = ticks.into_iter()
.tflo(|t| {
t.timestamp(|x| x.ts);
let price = t.prop(|x| x.price);
price.filter_f64(|x| x > 102.0)
})
.collect();
filter_map_f64 combines transform and filter in one step — return Some(value) to emit or None to suppress. This is useful when you want to conditionally remap values:
let capped: Vec<f64> = ticks.into_iter()
.tflo(|t| {
t.timestamp(|x| x.ts);
let price = t.prop(|x| x.price);
price.filter_map_f64(|x| {
if x < 0.0 { None } else { Some(x.ln()) }
})
})
.collect();
State machines with scan_f64
Here’s where closures pull ahead of extension traits. scan_f64 maintains mutable state across evaluations. You provide an initializer closure and a step closure. The step receives &mut state and the current value, and returns the output.
A custom EMA with arbitrary alpha:
let custom_ema: Vec<f64> = ticks.into_iter()
.tflo(|t| {
t.timestamp(|x| x.ts);
let price = t.prop(|x| x.price);
price.scan_f64(
|| 0.0_f64,
|state, x| {
*state = 0.9 * *state + 0.1 * x;
*state
},
)
})
.collect();
This is a standard exponential moving average, but expressed as a closure. You can change the alpha, add a bias term, or replace the formula entirely — without touching any struct definition or trait implementation.
scan2_f64 extends this to two input streams. You give it a reference to another Comp<R, f64>, and the step closure receives both values:
use tflo_examples::Detection;
let snr_per_hz: Vec<f64> = detections.into_iter()
.tflo(|t| {
t.timestamp(|x: &Detection| x.ts);
let snr = t.prop(|x: &Detection| x.snr);
let freq = t.prop(|x: &Detection| x.freq_mhz);
snr.scan2_f64(
&freq,
|| (0.0_f64, 0.0_f64),
|(snr_state, freq_state), snr, freq| {
*snr_state = 0.9 * *snr_state + 0.1 * snr;
*freq_state = 0.9 * *freq_state + 0.1 * freq;
if *freq_state == 0.0 { 0.0 } else { *snr_state / *freq_state }
},
)
})
.collect();
Two EMAs, one closure, zero boilerplate.
Where closures fit
Closures bridge the gap between “I can express this as a composition” and “I need a full custom node.” If your logic fits in a few lines and doesn’t need a reusable, named abstraction, a closure is the right tool. No new types. No registration. Just the formula.
Where closures fall short
Closure bodies are opaque to the graph engine. Rust compiles them, but tflo-core can’t inspect or display the formula inside. If you need a node that names itself in graph plans, closures won’t help — extension traits at least expose the method name, and an Operator carries its own name().
Also, closures top out at two inputs (scan2_f64). If you need a node with three or more wired inputs, or a clean reset() contract, you want Operator.
Custom Operator: when you need a reusable node
The Operator trait is the most structured path. It gives you eval(), reset(), and a name() for graph-plan output — a reusable, named node you can attach anywhere. It lives in tflo_core::operator and is re-exported from the prelude. It’s the same mechanism tflo-fintech uses to ship indicators like ADX and KAMA without any finance code in tflo-core.
An Operator receives one Computed per wired input — in declaration order — plus the record timestamp ts, and returns a NodeOutput. Wrap your Result<f64, Absent> with NodeOutput::computed(...). The require helper reads an input; because eval returns NodeOutput rather than Computed, use match instead of ? to propagate absence:
use std::collections::VecDeque;
use tflo_core::compile::{Absent, Computed, NodeOutput};
use tflo_core::operator::{require, Operator};
struct RateOfChange {
period: usize,
buffer: VecDeque<f64>,
}
impl Operator for RateOfChange {
fn eval(&mut self, inputs: &[Computed], _ts: i64) -> NodeOutput {
let current = match require(inputs, 0) {
Ok(v) => v,
Err(e) => return NodeOutput::computed(Err(e)),
};
self.buffer.push_back(current);
if self.buffer.len() > self.period + 1 {
self.buffer.pop_front();
}
if self.buffer.len() < self.period + 1 {
return NodeOutput::computed(Err(Absent::WarmingUp));
}
let n_periods_ago = self.buffer.front().copied().unwrap_or(current);
if n_periods_ago == 0.0 { return NodeOutput::computed(Err(Absent::DivideByZero)); }
NodeOutput::computed(Ok((current - n_periods_ago) / n_periods_ago))
}
fn reset(&mut self) { self.buffer.clear(); }
fn name(&self) -> &str { "rate_of_change" }
}
Attach it with comp.custom_node1(factory) for a single input, or Comp::custom_node(&a, &[&b], factory) to wire several inputs (a is the mandatory first input). The graph stores the factory, not the instance, so every per-key graph in keyed execution gets its own fresh state:
let roc: Vec<f64> = detections.into_iter()
.tflo(|t| {
t.timestamp(|x| x.ts);
let snr = t.prop(|x| x.snr);
snr.custom_node1(|| RateOfChange { period: 14, buffer: VecDeque::new() })
})
.collect();
Where Operator wins
You need a clean lifecycle. reset() gives you a defined contract for stream restarts. The struct can hold complex state — multiple buffers, configuration, lookup tables — that would be awkward to express in a closure.
You’re building a reusable component. If you plan to share this node across projects or teams, invest in the trait implementation. It documents the interface, enforces the contract, gives consumers a named type, and surfaces a readable name() in graph plans.
You need several wired inputs. Comp::custom_node takes a slice of input Comps and hands you all their resolved values per record — a natural fit for a multi-input detector. Closures top out at two inputs with scan2_f64.
Where Operator is overkill
For a one-line formula. I’ve written struct Clamp { min: f64, max: f64 } with a full trait impl, only to realize map_f64(|x| x.clamp(min, max)) would have done the same thing in four characters. Don’t do what I did.
For experimental code. When you’re iterating on a formula — trying different alphas, different smoothing functions, different input features — the compile-debug cycle of a trait implementation slows you down. Closures give you inline iteration. Compile when it stabilizes.
The decision table
Here’s the honest comparison:
| Extension trait | Closure primitive | Operator | |
|---|---|---|---|
| What it does | Composes existing operators | Inline custom logic | Full custom node |
| New runtime behavior | No — same node types | Yes — closure body is new | Yes — full trait impl |
| State management | None (pure composition) | Built-in via scan_f64 | Full control via struct |
| Reusable across crates | Yes (exported trait) | No (inline only) | Yes (exported type) |
| Inputs | Any (composition) | Up to 2 (scan2_f64) | Any (custom_node slice) |
| Named in graph plans | Method name only | Opaque (.named() helps) | Yes (name()) |
| Boilerplate | Minimal | Minimal | Moderate |
| Performance | Identical to built-in | Identical to built-in | Identical to built-in |
| Best for | Combining existing operators | Custom formulas, quick iteration | Reusable nodes, complex state |
Where the examples live
Every concept in this post has a full, compilable, CI-verified example in the tflo-examples crate:
-
Closure primitives —
tflo-examples/examples/custom-nodes— demonstratesmap_f64,filter_f64,filter_map_f64, andscan_f64with real streaming data -
Extension traits —
tflo-examples/examples/custom-composite—spread_ratio,mean_band,normalized_score, chaining custom with built-in -
Extension mechanisms overview —
tflo-examples/examples/three-extension-mechanisms— CEL, Rego, Rhai, and Rust native side by side -
Custom EMA with scan_f64 —
tflo-examples/examples/ema-the-forgetful-indicator— multiple alpha variants compared against built-in EMA -
Custom
Operator—tflo-examples/examples/custom-nodes— Rate of Change and an SNR filter implemented as full trait implementors
Each example is a cargo run away. They’re tested in CI, so they stay in sync with the API. No more copy-pasted snippets that drift.
A note on naming
Closures have one downside the graph engine can’t fix: they’re anonymous. When you write price.map_f64(|x| x.max(0.0)), the graph sees an opaque transform node. For simple local logic, that’s fine. For anything you might inspect later — graph plans, debug output, diagnostics — add .named("...").
price.map_f64(|x| x.max(0.0)).named("clamp_prices")
The name is metadata only. It won’t change behavior. But it will save you twenty minutes of head-scratching when you’re debugging a six-node graph and three nodes are labeled "anonymous_transform_2".
How I decide now
I walk through three questions:
Can I express this as a composition of existing nodes? If the algorithm is a combination of sma, std, arithmetic, and comparisons — extension trait. Zero new runtime, pure reuse.
Does this need internal state — a buffer, a running sum, a decay factor? If yes, can the state fit in a single closure? scan_f64 or scan2_f64. No struct, no registration, no ceremony.
Is this a reusable component I’ll share across projects? Or does it need three-plus wired inputs and a clean reset() contract? Operator. The boilerplate pays for itself in reuse.
That third question is the one I kept skipping. I’d reach for Operator first because it felt like the “proper” way to extend the system. It’s not. The proper way is whatever fits the problem with the least surface area.
Closures are first-class citizens in tflo. Use them like one.
— Matt
References
- Full source:
tflo-examplescrate on GitHub - tflo README — Custom Functional Graph Primitives
- 3 ways to extend tflo — CEL, Rego, Rhai, and
Operatorcompared