Skip to content

The Rust core

fnloom-core is the engine under the Python front end: the node model, the graph, the planner and the executor. It's a Rust crate of its own, with no dependencies and #![forbid(unsafe_code)]. It's generic over what a node runs, so you can use it to drive payloads other than Python functions.

Not on crates.io

The crate is not published yet. Depend on it by git (fnloom-core = { git = "https://github.com/kelsayed17/fnloom" }). It needs Rust 1.85 or newer.

The flow

  1. Specs. Each node is a NodeSpec<P>: a name, the names it reads (InputSpec, each Required or Optional), tags, and a payload P owned by you.
  2. Build. Graph::build(specs) checks for duplicate names and cycles, makes an input node of every name that is read but not declared, and gives each node a NodeId in sorted name order. A node's kind is NodeKind::Function(payload) or NodeKind::Input, so only function nodes carry a payload.
  3. Prepare. graph.prepare(&request) turns a Request (outputs, input names, override names) into a Plan: the steps to run, in topological order with ties broken by id, and the values each step releases.
  4. Run. plan.run(&graph, &mut evaluator, &inputs, &overrides, &mut observers) executes the steps and returns the requested outputs by name. A plan runs as often as you like. plan.run_by_id(&graph, &mut evaluator, lookup, &mut observers) runs it the same way but reads each input and override through lookup(action, node_id) and returns the outputs by NodeId, so an embedder that keys its values by node needs no name strings per run.

An example

A graph whose nodes add a constant to the sum of their inputs. The payload is the constant, and the evaluator is what gives payloads their meaning:

//! Embedding fnloom-core: a graph whose nodes add a constant to the sum of their inputs, planned
//! once and run for two input values.

use std::collections::{BTreeMap, BTreeSet};
use std::convert::Infallible;
use std::error::Error;

use fnloom_core::graph::Graph;
use fnloom_core::node::{Dependency, InputSpec, Node, NodeSpec};
use fnloom_core::plan::Request;
use fnloom_core::run::{Arg, Evaluate};

/// Runs a node: its payload, a constant, plus the sum of its arguments.
struct Adder;

impl Evaluate<i64> for Adder {
    type Value = i64;
    type Error = Infallible;

    fn evaluate(
        &mut self,
        _node: &Node<i64>,
        constant: &i64,
        args: &[Arg<'_, i64>],
    ) -> Result<i64, Infallible> {
        Ok(constant + args.iter().map(|arg| arg.value).sum::<i64>())
    }
}

/// A node called `name` that reads `reads` and adds `constant`.
fn node(name: &str, reads: &[&str], constant: i64) -> NodeSpec<i64> {
    NodeSpec {
        name: name.to_owned(),
        inputs: reads
            .iter()
            .map(|&read| InputSpec {
                name: read.to_owned(),
                dependency: Dependency::Required,
            })
            .collect(),
        tags: BTreeMap::new(),
        payload: constant,
    }
}

#[expect(clippy::print_stdout, reason = "an example shows its result")]
fn main() -> Result<(), Box<dyn Error>> {
    // `x` is read but declared by no spec, so the build makes it an input node.
    let graph = Graph::build(vec![
        node("a", &["x"], 1),
        node("b", &["a"], 10),
        node("c", &["a", "b"], 100),
    ])?;
    let request = Request {
        outputs: vec!["c".to_owned()],
        inputs: BTreeSet::from(["x".to_owned()]),
        overrides: BTreeSet::new(),
    };
    // Planned once: which nodes run, in what order, and when each value is released.
    let plan = graph.prepare(&request)?;
    for x in [1, 2] {
        let inputs = BTreeMap::from([("x".to_owned(), x)]);
        let outputs = plan.run(&graph, &mut Adder, &inputs, &BTreeMap::new(), &mut [])?;
        // a = 1 + x, b = 10 + a, c = 100 + a + b.
        assert_eq!(outputs.get("c"), Some(&(112 + 2 * x)));
        println!("x = {x}: c = {}", outputs["c"]);
    }
    Ok(())
}

It prints x = 1: c = 114 and x = 2: c = 116. Run it with cargo run -p fnloom-core --example embed.

Evaluate

Evaluate<P> is the seam that runs payloads. It names the value type flowing along edges (Value: Clone) and the error a payload can raise. The executor calls evaluate(node, payload, args) once per computed step, with the node's payload already taken out of its NodeKind::Function, and the arguments as Arg { node, name, value } sorted by id; an argument's node lets an embedder key per-node data by NodeId::index, as the Python binding does for its interned keyword names. An optional input that was not supplied is simply absent from args.

Observers

Observer<V, E>, with V and E the evaluator's value and error types, has four hooks, each with a default that does nothing:

  • pre_graph(), once before anything runs.
  • pre_node(node, args), before a computed node is evaluated, with the very args evaluate gets.
  • post_node(node, outcome), after it, with Ok(&value) or Err(&error).
  • post_graph(ok), once after the run ended.

The node hooks fire only around computed steps, and get the values by reference, so an observer that ignores them costs nothing. Every observer sees every event, in slice order, even when an earlier one fails; the first failure then aborts the run, and post_graph(false) still runs so observers can close what they opened. An error from post_graph is reported alongside the run's outcome, never instead of it.

Errors

  • BuildError: DuplicateName, or Cycle with the path, its first name repeated at the end.
  • PlanError: UnknownOutputs, UnknownInputs, UnknownOverrides or MissingInputs, checked in that order, each with every offending name sorted; UnknownNodes from the lineage queries.
  • RunError<E>: Node (a payload failed, with any observer errors raised while handling it), Observers (the first failing event and every error raised in it), MissingValues (a map lacked a name the plan reads; raised before any hook fires), or BrokenPlan (the plan's own state is inconsistent, which is a bug). Run a plan only on the graph it was prepared from: on another graph it may read the wrong nodes, or panic.

Guarantees

  • No recursion over graph depth. Build, cycle detection, the lineage queries, planning and running are all iterative; the tests build and query a chain of 50,000 nodes.
  • Deterministic. Ids follow sorted names, lineage queries return ids in order, and the execution order depends only on the graph and the request.
  • Values are released when dead. A step's release list drops every value no later step reads, unless it is a requested output.
  • Instruction counts are gated. CI measures build, prepare and run under callgrind on chain, fan-in and layered graphs, and fails on a rise of more than 2%; see Benchmarks.