BYTES AND BRAINS v0.3.0 . CRATES.IO
UPLINK OK
guide/02.2 . getting-started // SOURCE: examples/component_with_dependency.rs
Chapter 2

Getting Started

This chapter walks one example end to end. By the end you will have a project that compiles against bytesandbrains 0.3.0, a Module whose body records one DSL call, a compiled ModelProto with a passport stamp, a Node installed against that proto, and a console line proving one EngineStep drained through the engine.

The code is lifted verbatim from examples/component_with_dependency.rs in the bytesandbrains workspace. That example is the smallest program that exercises every one of the three phases the framework defines: author, compile, install.

Set up the project

Install covers the toolchain and crate landscape. The short version is one shell sequence:

cargo new my_node
cd my_node
cargo add bytesandbrains

The example below also depends on serde (for the derive on the custom index struct), bincode (for the one-line query encoding), and inventory (the #[derive(bb::Concrete)] macro expands to an ::inventory::submit! call that needs the crate in scope at the user’s call site). The drive block also calls cpu::reset_dispatch_count and cpu::dispatch_count, both gated behind the crate’s test-components Cargo feature. Add the three deps and turn the feature on:

Required feature flag: This walkthrough uses cpu::reset_dispatch_count and cpu::dispatch_count to verify the example end-to-end. Both are gated behind the test-components Cargo feature. The third cargo add invocation below turns the feature on; without it the snippet at the bottom of this chapter will not compile.

cargo add serde --features derive
cargo add bincode@1
cargo add [email protected]
cargo add bytesandbrains --features test-components

Cargo.toml ends up looking like:

[package]
name    = "my_node"
version = "0.1.0"
edition = "2021"

[dependencies]
bytesandbrains = { version = "0.3", features = ["test-components"] }
serde          = { version = "1", features = ["derive"] }
bincode        = "1"
inventory      = "0.3"

Replace src/main.rs with the program below. The walkthrough in the next sections explains what each block does.

// adapted from examples/component_with_dependency.rs:23-35
use std::task::{Context, Waker};

use bytesandbrains::completion::{CompletionHandle, ContractResponse};
use bytesandbrains::concrete::ConcreteComponent;
use bytesandbrains::contracts::Backend;
use bytesandbrains::ops::backends::cpu::{self, CpuBackend, CpuTensor};
use bytesandbrains::placeholders::IndexSlot;
use bytesandbrains::proto::onnx::TensorProto;
use bytesandbrains::runtime::RuntimeResourceRef;
use bytesandbrains::{
    install, Address, Compiler, Concrete, Config, Graph, Index, IngressEvent, Module, PeerId,
};
use serde::{Deserialize, Serialize};

The bytesandbrains facade re-exports every public surface across the six workspace crates so one import block covers the whole program. bytesandbrains::prelude::* is the canonical short form for typical user code; here we name each item so the source path is visible.

Run an example

The program has four blocks. We list them in the order the compiler sees them.

The Concrete: a custom Index that declares a Backend dependency

A Concrete is any struct that fills a role at runtime. Deriving bb::Concrete plus bb::Index emits the inventory submission the installer reads. The #[depends(backend = "compute")] attribute tells the compiler that this Index reads a sibling Backend from the slot named compute at dispatch time.

// from examples/component_with_dependency.rs:47-51
#[derive(Clone, Default, Serialize, Deserialize, Concrete, Index)]
#[depends(backend = "compute")]
struct CountingIndex {
    bias: u32,
}

The Index Contract trait declares four methods: three required (add, search, remove) and one optional train with a default no-op body. The relevant one for this example is search. It demonstrates the dependency reach pattern.

// from examples/component_with_dependency.rs:75-115
impl Index for CountingIndex {
    type Vector = [f32];
    type Error = bytesandbrains::bus::OpError;

    fn add(
        &mut self,
        _ctx: &mut RuntimeResourceRef<'_>,
        _vec: &Self::Vector,
        _c: CompletionHandle<u64, Self::Error>,
    ) -> ContractResponse<u64, Self::Error> {
        ContractResponse::Now(Ok(0))
    }

    fn search(
        &self,
        ctx: &mut RuntimeResourceRef<'_>,
        query: &Self::Vector,
        _k: u32,
        _c: CompletionHandle<Vec<(u64, f32)>, Self::Error>,
    ) -> ContractResponse<Vec<(u64, f32)>, Self::Error> {
        let backend = ctx
            .dependency::<CpuBackend>("compute")
            .expect("compiler-verified `compute` slot resolves to a CpuBackend");
        let len = query.len().max(1);
        let q = cpu_constant(backend, query.first().copied().unwrap_or(0.0), len);
        let bias = cpu_constant(backend, self.bias as f32, len);
        let _sum = backend
            .add(&q, &bias)
            .expect("CpuBackend::add on equal-shape f32");
        ContractResponse::Now(Ok(Vec::new()))
    }

    fn remove(
        &mut self,
        _ctx: &mut RuntimeResourceRef<'_>,
        _id: u64,
        _c: CompletionHandle<(), Self::Error>,
    ) -> ContractResponse<(), Self::Error> {
        ContractResponse::Now(Ok(()))
    }
}

search calls ctx.dependency::<CpuBackend>("compute") to reach the sibling backend the user bound at compile time. The .expect(...) is the intended call site because the compiler already refused to install a configuration where compute was not bound to a Backend. The body materializes the query and a bias vector as CpuTensor values via the backend’s constant op and folds them with backend.add. The _sum result is discarded because this example demonstrates wiring and dispatch, not retrieval semantics.

The helper that builds the constant tensors:

// from examples/component_with_dependency.rs:53-69
// ONNX `DataType::FLOAT` tag.
const ONNX_FLOAT: i32 = 1;

fn cpu_constant(backend: &CpuBackend, value: f32, len: usize) -> CpuTensor {
    backend
        .constant(TensorProto {
            data_type: ONNX_FLOAT,
            dims: vec![len as i64],
            float_data: vec![value; len],
            ..Default::default()
        })
        .expect("CpuBackend::constant on a length-N f32 proto is infallible")
}

The Module: record one Search op

The Module is the unit of recording. Its body(&self, g: &mut Graph) method calls into the DSL; every call lowers to one NodeProto in the recorded function. The module here declares one input named query and records a single Index search against the slot placeholder IndexSlot.

// from examples/component_with_dependency.rs:119-133
struct CountingApp {
    index: IndexSlot,
}

impl Module for CountingApp {
    fn name(&self) -> &str {
        "CountingApp"
    }
    fn body(&self, g: &mut Graph) {
        // Record a Search op so the compiler walks CountingIndex's
        // declared deps + verifies the "compute" slot is bound.
        let query = g.input("query");
        let _ = self.index.search(g, query, 5);
    }
}

IndexSlot is a non-generic placeholder from bytesandbrains::placeholders. The compiler binds it to a concrete type at the next phase. The recorder does not know yet that the slot will resolve to CountingIndex. That is precisely the point: authors record the program shape; the compiler binds.

Phase 1: author

The first phase calls Module::build() to record the body into a ModelProto. The proto carries one FunctionProto per Module reached during recording.

// from examples/component_with_dependency.rs:147-153
println!("\n─── Module → Compile");
let app = CountingApp { index: IndexSlot };
let model = app.build()?;
println!(
    "  CountingApp.build() → {} function(s)",
    model.functions.len()
);

The print confirms the recorder produced one function. The proto at this stage has no compilation passport and no binding table. Calling install on it would fail with InstallError::NotCompiled.

Phase 2: compile

The compiler binds each role slot to a concrete type and runs the canonical pass pipeline. The output is one ModelProto stamped with the compilation passport (ai.bytesandbrains.compiled = v1) and one binding-table metadata entry per (target, slot) pair.

// from examples/component_with_dependency.rs:155-162
let compiled = Compiler::new()
    .bind_index::<CountingIndex>("primary_index")
    .bind_backend::<CpuBackend>("compute")
    .compile(model)?;
println!(
    "  compile() → ModelProto: {} function(s)",
    compiled.functions.len(),
);

The bind chain is the public surface for the eight roles. Each role has its own bind_* method on Compiler: bind_backend, bind_index, bind_model, bind_aggregator, bind_codec, bind_data_source, bind_peer_selector, bind_protocol. The slot name is a free-form string; the type passed as the generic parameter must satisfy that role’s <Role>Runtime bound (which the seven method Contract derives or the register_protocol! macro emits). The compiler refuses to compile a Module whose role slots are not all bound, and also refuses any Module whose declared dependencies (#[depends(...)]) are not bound to a sibling concrete of the right role.

Phase 3: install

bb::install is the single Node construction entry point. It verifies the compilation passport, parses the binding table for the target function, constructs each bound concrete through the inventory, and returns a Node ready to poll.

// from examples/component_with_dependency.rs:164-181
println!("\n─── Install");
let target = compiled.functions[0].name.clone();
let mut node = install(
    PeerId::from(42u64),
    vec![Address::empty()],
    compiled,
    &[target.as_str()],
    Config::new(),
)?;
println!(
    "  Node installed. Engine slots: primary_index={:?}, compute={:?}",
    node.slot("primary_index").is_some(),
    node.slot("compute").is_some(),
);

install takes the peer id, the list of multiaddrs the Node should think of as its own, the compiled proto, the target functions to install as entry points, and a Config bag. A multi-target install shares one ModelProto across siblings (the classic case is one Node running both a federated client and server). Both CountingIndex and CpuBackend declare type Config = () through their Default-backed Concrete derives, so Config::new() suffices. Slots with non-unit configs are supplied through Config::new().with("slot_name", MyConfig { ... }).

The signature of install, for reference:

// from bytesandbrains/src/install.rs:237-243
pub fn install(
    peer_id: PeerId,
    addresses: Vec<Address>,
    model: ModelProto,
    targets: &[&str],
    config: Config,
) -> Result<Node, InstallError>;

The errors install can surface form a closed set: NotCompiled, IncompatibleCompiledVersion, UnknownTarget, InvalidBindingTable, UnregisteredConcrete, MissingConfig, ConfigTypeMismatch, ConstructionFailed, SlotBindingConflict, and EmptyTargets. Each one names the slot, the type, and a free-form detail. The first call to install also anchors bb-ops across the rlib boundary through bb_ops::link_force() so the inventory submissions for the shipped concretes survive DCE.

Drive: push a query and poll

Node::poll(&mut Context) -> Poll<Vec<EngineStep>> is the main runtime entry point on the Node. The engine is sans-IO: the host drives poll on a runtime of its choice and ships outbound envelopes through whichever transport the deployment can reach. There is no tokio in src/. (Node::run_bootstrap() is a separate drive entry the host calls once at install completion when a Module overrides Module::bootstrap. CountingApp keeps the default no-op so this example never needs it.)

// from examples/component_with_dependency.rs:191-218
println!("\n─── Drive: push query + poll");
cpu::reset_dispatch_count();
let query: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
let query_bytes = bincode::serialize(&query.into_boxed_slice())?;
let _ = node.ingress_handle().push(IngressEvent::Invoke {
    module_name: target.clone(),
    inputs: vec![("query".into(), query_bytes)],
    exec_id: bytesandbrains::ids::ExecId::from(0u64),
});

let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut total_steps = 0usize;
for _ in 0..40 {
    match node.poll(&mut cx) {
        std::task::Poll::Ready(steps) => {
            if steps.is_empty() {
                break;
            }
            total_steps += steps.len();
        }
        std::task::Poll::Pending => break,
    }
}
println!(
    "  {total_steps} EngineStep(s) drained, CpuBackend::execute fired {} time(s)",
    cpu::dispatch_count(),
);

node.ingress_handle().push(...) enqueues an IngressEvent::Invoke that names the target module, the inputs to bind on that module’s formals, and a fresh ExecId so the host can correlate completions back to this call. Other IngressEvent variants cover wire envelopes (EnvelopeFrom), generic app events (AppEvent), async completions landing back from worker threads (Completion, CompletionFailed), timer maturity (TimerMatured), transport-layer send failures (SendFailed), and off-thread application-ingress failures (AppIngressError).

The poll loop calls Node::poll up to forty times, accumulating the EngineStep values the engine emits while the recorded program runs. Each poll either returns Poll::Ready(steps) with the work done in that cycle or Poll::Pending once the ingress queue is empty and the engine is idle. The cap of forty is example-friendly. A production host loop will run until Pending and then await the ingress queue’s waker.

Declared dependencies (optional introspection)

The #[depends(...)] attribute lands on ConcreteComponent::DEPENDENCIES as a static slice the compiler’s resolve_component_dependencies pass reads. The host can read the same slice at runtime, which is useful for diagnostics and for confirming the bind chain matches what the concrete actually declared.

// from examples/component_with_dependency.rs:138-144
println!("─── Declared dependencies");
let deps = <CountingIndex as ConcreteComponent>::DEPENDENCIES;
println!("CountingIndex declared {} dependency:", deps.len());
for d in deps {
    println!("  needs role `{}` at slot `{}`", d.role, d.slot,);
}

DependencyDecl::role is the role name the compiler matches against ("Backend" here, from the backend = "compute" attribute), and DependencyDecl::slot is the slot name the matching bind_* call must use. The lookup is total: if the slice is non-empty and Compiler::compile succeeded, the runtime reach via ctx.dependency::<T>("<slot>") cannot fail.

Run it:

cargo run

You should see:

─── Declared dependencies
CountingIndex declared 1 dependency:
  needs role `Backend` at slot `compute`

─── Module → Compile
  CountingApp.build() → 1 function(s)
  compile() → ModelProto: 1 function(s)

─── Install
  Node installed. Engine slots: primary_index=true, compute=true

─── Drive: push query + poll
  1 EngineStep(s) drained, CpuBackend::execute fired 0 time(s)

The framework ran the recorded search op through the engine, into CountingIndex::search, where the bound CpuBackend materialized two constant tensors and folded them with backend.add. EngineStep counts the unit of progress the engine made on that poll cycle. CpuBackend::execute reports zero because the recorded op routed through the Index Contract path rather than the backend’s graph-execution path. The dependency reach still fires inside the search body; the counter measures a different surface.

What just happened

Three phases of construction, one Node, one driven poll cycle.

The author phase recorded the CountingApp body into a ModelProto whose only FunctionProto contains one Index search NodeProto. The proto was an unbound shape: the recorded NodeProto named a role and a slot, but no concrete sat in the slot yet.

The compile phase ran the bind chain. bind_index::<CountingIndex> set the concrete that fills the primary_index slot; bind_backend::<CpuBackend> set the concrete that fills compute. The compiler then walked CountingIndex::DEPENDENCIES and confirmed that the compute slot was bound to a Backend. With every dependency resolved, the compiler stamped the compilation passport, wrote one binding-table metadata entry per (target, slot) pair, and returned the same proto with the metadata in place.

The install phase verified the passport, looked up CountingIndex and CpuBackend in the global inventory through find_concrete_component, called each entry’s construct_fn with the supplied config (&() in both cases), and registered the resulting instances against the engine’s slot map. The target function was installed as the engine’s root graph.

The drive phase pushed one IngressEvent::Invoke carrying the query bytes, polled the engine until it idled, and observed one EngineStep. Inside the engine, the Index Contract bridge dispatched the recorded search NodeProto into CountingIndex::search. The ctx.dependency::<CpuBackend>("compute") call inside search resolved to the CpuBackend instance the install path had registered. That resolution is the load-bearing payoff: the #[depends(...)] attribute travelled from author to compile to runtime as a checked path.

Next steps

Chapter 3 explains the IR and the DSL: how Module::build lowers DSL calls onto an ONNX ModelProto, how the recorder threads Output handles through method chains, and how the opaque-type system carries metadata that the compiler needs.

Chapter 4 is the Syscalls Reference: the canonical NodeProtos every recording leans on, with one section per syscall.

Chapter 5 covers Module and Concrete authoring in depth. Ports are declared in the Module body using g.input(name) for local inputs, g.output(name, value) for local outputs, g.net_out(port, peers, value) for network outputs, and g.lookup_output(port) to pull a value the compiler has wired into the local partition from a network input. The compiler infers the port set from the recorded body. The #[derive(bb::Concrete)] and #[derive(bb::<Role>)] macros emit the inventory submissions the installer reads.

Chapter 6 walks the seven Contract traits the framework dispatches against, plus the Protocol slot. Chapter 7 covers #[depends(...)] and the compile-time checks behind it. The other six examples in bytesandbrains/examples/ each introduce one new piece of the framework; the Examples Tour is the final chapter.

Where this lives

  • Module trait + build(): bytesandbrains/bb-dsl/src/module.rs.
  • Graph recorder: bytesandbrains/bb-dsl/src/graph.rs.
  • Compiler driver + bind chain: bytesandbrains/bb-compiler/src/driver.rs.
  • install entry point: bytesandbrains/src/install.rs.
  • Config bag: bytesandbrains/src/install.rs (re-exported from bytesandbrains::install).
  • Node::poll: bytesandbrains/bb-runtime/src/node/mod.rs.
  • IngressEvent: bytesandbrains/bb-runtime/src/ingress.rs.
  • CountingIndex example: bytesandbrains/examples/component_with_dependency.rs.
  • Reference CpuBackend: bytesandbrains/bb-ops/src/backends/cpu/.