extensions custom-nodes operator-trait

Custom nodes with the Operator trait

The built-in operators cover a lot. They don't cover everything. Here's how the Operator trait lets you define your own runtime computation nodes for the tflo graph.

8 min read

The built-in operators cover a lot. They don’t cover everything.

Maybe you need a domain-specific calculation that doesn’t fit SMA/EMA/RSI — an RF dwell-time estimator, a custom anomaly score, a protocol-specific health metric. Maybe it’s a proprietary detector your team doesn’t want to open-source. Maybe you just want to prototype a new operator idea without waiting for it to land in tflo-ops.

Custom nodes are how you extend the graph. The Operator trait defines the contract — implement it, and your logic gets wired into the same execution engine that runs built-in nodes. This is the exact mechanism the tflo-fintech crate uses to ship ADX, ATR, and KAMA without any finance-specific code living in tflo-core.

The trait

pub trait Operator: Send + Sync + 'static {
    /// Evaluate the node against the current record's resolved inputs and timestamp.
    fn eval(&mut self, inputs: &[Computed], ts: i64) -> NodeOutput;

    /// Reset internal state to the freshly-constructed condition.
    fn reset(&mut self) {}

    /// Human-readable name, used in graph-plan and debug output.
    fn name(&self) -> &str { "operator" }

    /// Serialize state for checkpointing (default: not checkpointable).
    fn save(&self) -> Option<Vec<u8>> { None }

    /// Restore state from bytes produced by `save()`.
    fn load(&mut self, bytes: &[u8]) -> Result<(), OperatorLoadError> { /* ... */ }
}

Only eval is mandatory. It receives one Computed per wired input — in declaration order — plus the record timestamp ts, and returns a NodeOutput. Wrap a Result<f64, Absent> result with NodeOutput::computed(...). Return NodeOutput::computed(Err(Absent::WarmingUp)) while the node is still warming up. reset, name, save, and load all have defaults — overriding save/load makes the node checkpointable; the default leaves it out of snapshots.

The free require(inputs, idx) helper reads an input and returns Computed — because eval returns NodeOutput (not Computed), the ? operator no longer applies. Use match instead: match require(inputs, 0) { Ok(v) => v, Err(e) => return NodeOutput::computed(Err(e)) }.

Build a Rate-of-Change node

Rate of Change measures how much a value has changed relative to N records ago: (current - N_records_ago) / N_records_ago. (tflo-ops actually ships this as the rate_of_change operator — but it makes a clean, self-contained example of the trait.) Here’s the full implementation:

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 RateOfChange {
    pub fn new(period: usize) -> Self {
        Self {
            period,
            buffer: VecDeque::with_capacity(period + 1),
        }
    }
}

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)); // not enough data yet
        }

        let n_records_ago = self.buffer.front().copied().unwrap_or(current);
        if n_records_ago == 0.0 {
            return NodeOutput::computed(Err(Absent::DivideByZero));
        }

        NodeOutput::computed(Ok((current - n_records_ago) / n_records_ago))
    }

    fn reset(&mut self) {
        self.buffer.clear();
    }

    fn name(&self) -> &str {
        "rate_of_change"
    }
}

That’s it. eval reads its single input with require — which returns Err(Absent::WarmingUp) if the node is mis-wired — and wraps the result in NodeOutput::computed(...). The trait carries no step/context plumbing: an operator sees only resolved Computed inputs plus the record timestamp, which keeps the contract small.

Wiring it into the graph

An Operator drops straight into a .tflo() closure through Comp::custom_node or its single-input shorthand Comp::custom_node1:

use tflo_core::prelude::*;
use tflo_core::operator::Operator;

#[derive(Clone)]
struct Reading {
    ts: i64,
    value: f64,
}

let roc: Vec<f64> = readings.into_iter()
    .tflo(|t| {
        t.timestamp(|x| x.ts);
        let value = t.prop(|x| x.value);
        // single-input node: consumes `value`
        value.custom_node1(|| RateOfChange::new(14))
    })
    .collect();

custom_node1 takes a factory closure — || RateOfChange::new(14) — not a node instance. The graph stores the factory so it can mint a fresh, independent node for every compiled graph, including each per-key graph in keyed execution. That’s what keeps state from leaking between keys.

For a node that reads more than one input, use the multi-input Comp::custom_node. The inputs arrive in eval in exactly the order you list them:

use tflo_core::compile::{Computed, NodeOutput};
use tflo_core::operator::{require, Operator};

struct Spread;

impl Operator for Spread {
    fn eval(&mut self, inputs: &[Computed], _ts: i64) -> NodeOutput {
        // Absent in either input short-circuits the whole node.
        let bid = match require(inputs, 0) { Ok(v) => v, Err(e) => return NodeOutput::computed(Err(e)) };
        let ask = match require(inputs, 1) { Ok(v) => v, Err(e) => return NodeOutput::computed(Err(e)) };
        NodeOutput::computed(Ok(ask - bid))
    }
}

let spreads: Vec<f64> = readings.into_iter()
    .tflo(|t| {
        t.timestamp(|x| x.ts);
        let high = t.prop(|x| x.high);
        let low = t.prop(|x| x.low);
        // inputs reach eval() as [high, low]
        Comp::custom_node(&high, &[&low], || Spread)
    })
    .collect();

custom_node panics if you pass an empty input slice — every node needs at least one input to attach to.

Use constants via the builder

Need a constant fed into your node? TFlowBuilder::constant() registers a Node::Const and hands back a Comp<R> you can chain or pass as an input:

use tflo_core::prelude::*;

// Inside a .tflo() closure:
let threshold = t.constant(0.05);
let signal = value.cross_above(&threshold);

Operators are first-class

Once attached, an operator compiles into the same execution pipeline as everything else. Same topological ordering guarantees. Same absence propagation — returning NodeOutput::computed(Err(Absent::WarmingUp)) holds downstream nodes back exactly like a built-in window that hasn’t filled. Same snapshot/restore lifecycle — implement save/load and the node checkpoints with the rest of the graph. The only difference is that you wrote the eval body.

That’s the whole extension story: implement one trait, attach it with one method, and your logic runs shoulder to shoulder with the built-ins.

Source code

The full working example is available on GitHub: tflo-examples/examples/custom-nodes

For composite operator patterns (extension traits), see: tflo-examples/examples/custom-composite

— Matt