extensions closures primitives architecture examples

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.

17 min read

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:

MethodWhat it doesClosure shape
map_f64Transform every value|x| -> f64
map2_f64Transform from two inputs(&other, |x, y| -> f64)
filter_f64Keep or drop values|x| -> bool
filter_map_f64Transform + optionally drop|x| -> Option<f64>
scan_f64Stateful transform (one input)(|| init, |state, x| -> f64)
scan2_f64Stateful 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 traitClosure primitiveOperator
What it doesComposes existing operatorsInline custom logicFull custom node
New runtime behaviorNo — same node typesYes — closure body is newYes — full trait impl
State managementNone (pure composition)Built-in via scan_f64Full control via struct
Reusable across cratesYes (exported trait)No (inline only)Yes (exported type)
InputsAny (composition)Up to 2 (scan2_f64)Any (custom_node slice)
Named in graph plansMethod name onlyOpaque (.named() helps)Yes (name())
BoilerplateMinimalMinimalModerate
PerformanceIdentical to built-inIdentical to built-inIdentical to built-in
Best forCombining existing operatorsCustom formulas, quick iterationReusable nodes, complex state

Where the examples live

Every concept in this post has a full, compilable, CI-verified example in the tflo-examples crate:

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

  1. Full source: tflo-examples crate on GitHub
  2. tflo README — Custom Functional Graph Primitives
  3. 3 ways to extend tflo — CEL, Rego, Rhai, and Operator compared