Roles and Contracts
A Role is the composition contract every Component obeys. The previous
chapter walked the authoring entry point: a Module records DSL calls
onto a Graph, the Compiler stamps slot bindings onto the resulting
ModelProto, and install hands the wired program to a Node. This
chapter answers the question that pattern leaves open: what does it
take to write a Component? You implement one Contract trait per Role,
derive the bridge that wires it into the engine, and bind your type at
a slot when the compiler chain runs. Everything else is plumbing the
framework supplies for free.
What a Role is
There are three pieces that fit together. A Role is the abstract composition contract: a Rust trait the framework owns, with one method per atomic operation the slot supports. A Component is your concrete type that implements the Role. A Module is the recording that names the slot during graph construction, so the compiler knows where to attach your Component when the binding chain runs.
The recording side and the dispatch side never meet directly. When a
Module records ModelSlot.forward(g, batch), the DSL writes a
NodeProto into the Graph under ai.bytesandbrains.role.model. When
the engine dispatches that NodeProto at runtime, it routes through the
slot table to the ComponentRef bound at compile time, downcasts the
slot inputs to your Component’s associated types, and calls your
forward method. The bridge between those two sides is what the Role
derive emits.
The result is a runtime triangle. The Module owns the program shape. The Compiler owns the bindings table. The Component owns the typed implementation. None of the three knows the others’ concrete types. The framework supplies the routing layer that lets them compose through named slots.
The Roles in v0.3.0
There are eight Roles. Seven share a uniform Contract trait shape (method-per-op, ctx plus typed inputs, completion handle, returns ContractResponse). The eighth, Protocol, hosts bring-your-own-protocol implementations whose op catalog is declared per-protocol rather than fixed by a framework trait.
| Role | Purpose | Compile-time binder |
|---|---|---|
| Backend | Tensor compute primitives (Add, MatMul, Reshape, …). | Compiler::bind_backend |
| Model | Forward, backward, parameter snapshot, delta apply. | Compiler::bind_model |
| Aggregator | Federated reduction over per-peer contributions. | Compiler::bind_aggregator |
| Codec | Typed in/out storage bridge (quantizers, dtype lifts). | Compiler::bind_codec |
| DataSource | Batch loader / sample stream. | Compiler::bind_data_source |
| Index | Vector index (add, search, remove, optional train). | Compiler::bind_index |
| PeerSelector | Peer-selection protocol (random, near-key, all). | Compiler::bind_peer_selector |
| Protocol | Custom protocol with its own atomic opset. | Compiler::bind_protocol |
Every Role except Protocol has a matching Contract trait in
bytesandbrains::contracts and a matching derive
(#[derive(bytesandbrains::Model)], #[derive(bytesandbrains::Index)],
and so on). Protocols use the declarative bytesandbrains::register_protocol!{}
macro because their op catalog is not fixed.
Implementing a Role: Model
A Role is a composition contract. You write a typed struct, implement the Contract trait for the Role, derive the framework bridge, and bind the type at a named slot during compile. The engine dispatches through your methods without knowing your type concretely.
This is a minimal Model that holds a single weight, multiplies inputs by it on the forward pass, and adds a delta to it when the aggregator hands one down.
use bytesandbrains::bus::OpError;
use bytesandbrains::completion::{CompletionHandle, ContractResponse};
use bytesandbrains::contracts::Model as ModelContract;
use bytesandbrains::runtime::RuntimeResourceRef;
#[derive(
Clone, Debug, Default,
serde::Serialize, serde::Deserialize,
bytesandbrains::Concrete,
bytesandbrains::Model,
)]
struct LinearModel { w: f32 }
impl ModelContract for LinearModel {
type Tensor = [f32];
type Error = OpError;
fn forward(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
input: &Self::Tensor,
_c: CompletionHandle<Box<Self::Tensor>, Self::Error>,
) -> ContractResponse<Box<Self::Tensor>, Self::Error> {
let x = input.first().copied().unwrap_or(0.0);
ContractResponse::Now(Ok(vec![self.w * x].into_boxed_slice()))
}
fn apply_delta(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
delta: &Self::Tensor,
_c: CompletionHandle<(), Self::Error>,
) -> ContractResponse<(), Self::Error> {
if let Some(d) = delta.first().copied() { self.w += d; }
ContractResponse::Now(Ok(()))
}
fn load_parameters(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
params: &Self::Tensor,
_c: CompletionHandle<(), Self::Error>,
) -> ContractResponse<(), Self::Error> {
if let Some(w) = params.first().copied() { self.w = w; }
ContractResponse::Now(Ok(()))
}
fn backward(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
_grad: &Self::Tensor,
_c: CompletionHandle<(), Self::Error>,
) -> ContractResponse<(), Self::Error> {
ContractResponse::Now(Ok(()))
}
fn compute_loss(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
_input: &Self::Tensor,
_target: &Self::Tensor,
_c: CompletionHandle<f32, Self::Error>,
) -> ContractResponse<f32, Self::Error> {
ContractResponse::Now(Ok(0.0))
}
fn params(
&self,
_ctx: &mut RuntimeResourceRef<'_>,
_c: CompletionHandle<Box<Self::Tensor>, Self::Error>,
) -> ContractResponse<Box<Self::Tensor>, Self::Error> {
ContractResponse::Now(Ok(vec![self.w].into_boxed_slice()))
}
}
Three pieces of plumbing:
-
#[derive(bytesandbrains::Concrete)]registersLinearModelin the framework’s inventory so the compiler can resolve it by type name at bind time. Theserdederives participate in the engine’s snapshot/restore protocol; every state-holding Component is serializable. -
#[derive(bytesandbrains::Model)]is the Role bridge. It emits the glue between your typedModelContractimpl and the framework’s internal Role trait. -
impl ModelContract for LinearModelis the public surface. You write the methods. The framework calls them.
The Contract trait fixes the surface: type Tensor: ?Sized + Storage
is the storage position your model operates on ([f32] for flat f32
tensors, AnyTensor for backend-mediated storage, a custom packed
type for specialized dtypes). type Error is your typed error and
travels back to the dispatched op when a call fails. The six methods
are the atomic ops the framework dispatches against. The DSL surface
bytesandbrains::placeholders::ModelSlot records calls under the
ai.bytesandbrains.role.model domain, and the engine routes them
through the bridge derive into your impl.
At compile time you bind LinearModel to a named slot:
let compiled = Compiler::new()
.bind_model::<LinearModel>("model")
.compile(model_proto)?;
The slot name "model" is the same string the Module recorded against
when it created the ModelSlot placeholder. Mismatched names surface
as a BuildError from compile(). No two bind_model calls can name
the same slot with different Components; the install pass collapses
shared bindings across targets and rejects conflicts.
The Contract response pattern
Every Contract method returns a ContractResponse<R, E> rather than a
plain Result<R, E>. The framework needs the extra discriminant
because some Components answer inline (CPU math, a flat lookup) and
some park the call until off-thread work completes (a worker pool, a
GPU stream, a remote RPC). Both shapes are first-class.
The three response variants are:
ContractResponse::Now(Ok(value))says the result is ready inline. The framework returns the value to the dispatched op immediately and ignores the completion handle.ContractResponse::Now(Err(e))says the call failed synchronously. The framework propagates the error to the dispatched op.ContractResponse::Latersays you retained the completion handle. The framework parks the op behind the handle’s CommandId until your completion arrives off-thread.
The async path uses the CompletionHandle<R, E> your method receives
as its last parameter. You hand the handle to whatever runtime is
backing your Component (a worker thread, an async task, a device
event loop), let it produce the result, and then call
handle.complete(Ok(result)) from outside the engine. The completion
lands on the Node’s ingress queue. The next node.poll() drains the
ingress and unparks the suspended op.
The shape below is the canonical pattern from
examples/custom_index_hnsw.rs. It ships every Contract call to a
worker thread that owns the data, then returns Later so the engine
can dispatch other work while the search runs.
use std::sync::mpsc;
use bytesandbrains::bus::OpError;
use bytesandbrains::completion::{CompletionHandle, ContractResponse};
use bytesandbrains::contracts::Index as IndexContract;
use bytesandbrains::runtime::RuntimeResourceRef;
enum WorkItem {
Search { q: Vec<f32>, k: u32, c: CompletionHandle<Vec<(u64, f32)>, OpError> },
}
#[derive(
Default, Clone, Debug,
serde::Serialize, serde::Deserialize,
bytesandbrains::Concrete,
bytesandbrains::Index,
)]
struct WorkerIndex {
#[serde(skip)]
tx: Option<std::sync::Arc<std::sync::Mutex<mpsc::Sender<WorkItem>>>>,
}
impl IndexContract for WorkerIndex {
type Vector = [f32];
type Error = OpError;
fn add(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
_v: &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,
completion: CompletionHandle<Vec<(u64, f32)>, Self::Error>,
) -> ContractResponse<Vec<(u64, f32)>, Self::Error> {
if let Some(tx) = &self.tx {
let _ = tx.lock().unwrap().send(WorkItem::Search {
q: query.to_vec(), k, c: completion,
});
ContractResponse::Later
} else {
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 is the async branch. The other two methods stay synchronous,
which is fine; you mix Now and Later per method as the work demands.
The worker side (not shown) pulls each WorkItem off the channel,
runs whatever blocking computation it needs, and calls
c.complete(Ok(results)) when done. That call serializes the result
through the handle’s sink and routes it back into the engine’s
ingress as a completion event. The engine resumes the parked op with
the value on the next poll.
The seam is general. The framework does not care whether your async
path is a std::thread worker, a tokio task, a custom event loop,
or a remote RPC. All it needs is the completion handle and the
guarantee that complete() will be called exactly once. That
guarantee is what makes Components composable with backends, runtimes,
and services the framework does not know about.
Snapshot and restore
Every Component is serializable. The serde::Serialize plus
serde::Deserialize plus Default bounds on the derives are not
optional ergonomics; the framework owns a snapshot protocol that
periodically captures Component state for replay-debugging and Node
migration, and your struct participates in it by virtue of those
derives.
The snapshot path is framework-driven. The engine mints a snapshot key
for each ComponentRef at compile time and binds it to your concrete
type. When a snapshot fires, the engine reads your Component through
the serde derives, writes the bytes into the snapshot store, and
ships them across the wire or to disk. Restore reverses the path:
the framework constructs a fresh instance through the inventory
default, then layers your serialized state on top.
Three implications for authors. The struct sketch below shows the shape (the Contract impl is elided; see the section above for the full Model impl):
#[derive(
Clone, Debug, Default,
serde::Serialize, serde::Deserialize,
bytesandbrains::Concrete,
bytesandbrains::Model,
)]
struct MyModel {
w: f32,
optimizer_state: Vec<f32>,
#[serde(skip)]
scratch_buffer: Vec<f32>,
}
A compile-tested version with every Model Contract method filled in
lives in this chapter’s verification crate; the field shape is the
load-bearing pattern. First, every field that contributes to your
Component’s observable state needs to round-trip through serde. w
and optimizer_state above both serialize. Second, transient scratch
space you do not need to persist across snapshots uses
#[serde(skip)]; on deserialize those fields come back as their
Default value. Third, anything that cannot be serialized at all (an
open file handle, a worker thread’s sender) is #[serde(skip)] and
lazily re-initialized on first use. The async Worker example earlier
in this chapter shows this pattern: the mpsc::Sender is skipped,
and the impl handles the None case until the worker is wired.
You never call the snapshot path yourself. The framework drives it. What you do is keep your struct in a shape that survives the round-trip: derive the four serde-side traits, mark transients as skipped, and let the framework handle the rest.
Multi-Role Components
A Component can implement more than one Role. The Kademlia shape is
the canonical motivation: a single struct holds the routing table, a
peer view, and the protocol’s atomic opset. It implements Index
(routing keys to peers), PeerSelector (sampling from the view), and
Protocol (the Ping / FindNode opset). At install time the compiler
binds the same instance at all three slots, and the engine dispatches
each Role’s ops into the same &mut self.
The pattern is straightforward: derive Concrete once and stack the
Role derives, then write one Contract impl per Role.
use bytesandbrains::bus::OpError;
use bytesandbrains::completion::{CompletionHandle, ContractResponse};
use bytesandbrains::contracts::{Index as IndexContract, PeerSelector as PeerSelectorContract};
use bytesandbrains::contracts::peer_selector::SelectParams;
use bytesandbrains::runtime::RuntimeResourceRef;
use bytesandbrains::PeerId;
#[derive(
Clone, Debug, Default,
serde::Serialize, serde::Deserialize,
bytesandbrains::Concrete,
bytesandbrains::Index,
bytesandbrains::PeerSelector,
)]
struct RoutingNode { seed: u64 }
impl IndexContract for RoutingNode {
type Vector = [f32];
type Error = OpError;
fn add(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
_v: &Self::Vector,
_c: CompletionHandle<u64, Self::Error>,
) -> ContractResponse<u64, Self::Error> {
ContractResponse::Now(Ok(self.seed))
}
fn search(
&self,
_ctx: &mut RuntimeResourceRef<'_>,
_query: &Self::Vector,
_k: u32,
_c: CompletionHandle<Vec<(u64, f32)>, Self::Error>,
) -> ContractResponse<Vec<(u64, f32)>, Self::Error> {
ContractResponse::Now(Ok(vec![(self.seed, 0.0)]))
}
fn remove(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
_id: u64,
_c: CompletionHandle<(), Self::Error>,
) -> ContractResponse<(), Self::Error> {
ContractResponse::Now(Ok(()))
}
}
impl PeerSelectorContract for RoutingNode {
type Error = OpError;
fn select(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
_params: SelectParams,
_c: CompletionHandle<Vec<PeerId>, Self::Error>,
) -> ContractResponse<Vec<PeerId>, Self::Error> {
ContractResponse::Now(Ok(Vec::new()))
}
fn current_view(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
_c: CompletionHandle<Vec<PeerId>, Self::Error>,
) -> ContractResponse<Vec<PeerId>, Self::Error> {
ContractResponse::Now(Ok(Vec::new()))
}
}
The compiler chain binds the same type at both slots:
let compiled = Compiler::new()
.bind_index::<RoutingNode>("routing")
.bind_peer_selector::<RoutingNode>("routing")
.compile(model_proto)?;
The install path notices both bindings refer to the same concrete
type, instantiates exactly one RoutingNode, and registers one
ComponentRef visible through both slots. Every Index op fired by the
graph dispatches into the IndexContract methods on that instance.
Every PeerSelector op dispatches into the PeerSelectorContract
methods on the same instance. State the routing layer writes through
one Role’s methods is observable through the other Role’s methods on
the next call.
The engine never sees a list of which Roles a Component carries. The dispatch table is keyed on slot and op type; the bridge derives expose exactly one method per op the Role declares, and inheritance from multiple bridges composes through Rust’s normal trait resolution. A graph that fires only Index ops never touches the PeerSelector methods, and vice versa.
Dependencies
Components compose. The Aggregator you bind at "server_aggregator"
might need to reach the Backend you bound at "compute" to run a
weighted sum. The Codec you bind at "int8" might need a calibration
tensor materialized on the same backend the rest of the graph uses.
The framework gives you one mechanism for both cases: the #[depends]
attribute on #[derive(bytesandbrains::Concrete)].
The pattern is at examples/component_with_dependency.rs: an Index
that declares it needs a Backend at a named slot, reaches the bound
sibling through ctx.dependency::<T>("<slot>"), and composes against
the backend’s typed surface.
use bytesandbrains::bus::OpError;
use bytesandbrains::completion::{CompletionHandle, ContractResponse};
use bytesandbrains::contracts::Index as IndexContract;
use bytesandbrains::ops::backends::cpu::CpuBackend;
use bytesandbrains::runtime::RuntimeResourceRef;
#[derive(
Clone, Debug, Default,
serde::Serialize, serde::Deserialize,
bytesandbrains::Concrete,
bytesandbrains::Index,
)]
#[depends(backend = "compute")]
struct DelegatingIndex { bias: u32 }
impl IndexContract for DelegatingIndex {
type Vector = [f32];
type Error = OpError;
fn add(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
_v: &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");
ContractResponse::Now(Ok(Vec::new()))
}
fn remove(
&mut self,
_ctx: &mut RuntimeResourceRef<'_>,
_id: u64,
_c: CompletionHandle<(), Self::Error>,
) -> ContractResponse<(), Self::Error> {
ContractResponse::Now(Ok(()))
}
}
Two pieces are doing work. The #[depends(backend = "compute")]
attribute on the Concrete derive declares the relationship. The
compiler reads it during a dedicated pass, verifies that any binding
chain producing a graph that touches DelegatingIndex also binds a
Backend at "compute", and stamps the slot reference onto every
NodeProto the Index contributes. If the user forgets the matching
.bind_backend::<CpuBackend>("compute") call on the compile chain,
Compiler::compile returns a typed error before install runs.
The runtime side is ctx.dependency::<T>("<slot>"). The Contract’s
ctx parameter is the per-dispatch surface the engine hands your
method. It resolves the named slot to a typed reference at the type
you ask for. The lookup is total once Compiler::compile succeeds, so
.expect("compiler-verified ...") is the intended call site:
production impls treat the dependency as infrastructure and let it
panic on a framework invariant breach. Authors who want graceful
degradation can match on the Result instead.
Multiple #[depends] attributes stack. A complex Component might
declare both a Backend at "compute" and a Codec at "transit", and
its search impl reaches both through separate ctx.dependency calls
on the same ctx. The compiler walks the union of declared
dependencies and verifies every slot is bound. The accepted Role names
inside #[depends] are index, aggregator, model, codec,
data_source, peer_selector, backend, and protocol; the slot
string can be any name the binding chain uses.