BYTES AND BRAINS v0.3.0 . CRATES.IO
UPLINK OK
guide/07 . dependencies // SOURCE: bytesandbrains/docs/DEPENDENCIES.md
Chapter 7

Dependencies

Chapter 6 introduced the eight roles (seven method-style Contracts plus the Protocol slot) and noted in passing that non-Backend Contracts reach sibling concretes through ctx.dependency::<T>("<slot>"). This chapter is the canonical reference for that mechanism. Authors declare which sibling components they need by writing one #[depends(<role> = "<slot>")] attribute per dependency on the struct deriving bb::Concrete. The compiler verifies the wiring at compile time. The runtime returns a typed reference at op-dispatch time. The whole surface is two strings on the DependencyDecl struct, one accessor on RuntimeResourceRef, and one compiler pass.

The mechanism is symmetric across roles. The same attribute that lets an Index reach a Backend lets a Model reach an Index, an Aggregator reach a Backend, or any future custom Component reach any other. There is no backend-specific special case. The framework’s engine carries one generic slot registry keyed by author-chosen slot name; every Component lives in that map.

Declaring dependencies

Write #[depends(<role> = "<slot>")] on the struct that carries the bb::Concrete derive. The role identifier is the lowercase trait name (index, aggregator, model, codec, data_source, peer_selector, backend, protocol). The slot string is the author-chosen name the bind chain uses for that sibling.

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

This is the struct declaration slice of the worked example. The Index derive expects a sibling impl Index for CountingIndex { ... } block; the full impl lives in bytesandbrains/examples/component_with_dependency.rs and is shown in pieces later in this chapter.

CountingIndex is an Index concrete that needs one sibling: a Backend bound at the slot named "compute". The author chooses "compute". The framework does not reserve any slot names.

Multiple dependencies stack. Stack them with multiple attributes, or list them inside one attribute with commas. The two forms are equivalent.

#[derive(bb::Concrete, bb::Model)]
#[depends(backend = "compute")]
#[depends(index = "knowledge_base")]
pub struct RagModel { /* ... */ }

// Equivalent:
#[depends(backend = "compute", index = "knowledge_base")]
pub struct RagModel { /* ... */ }

The valid role identifiers are exactly the eight role names: index, aggregator, model, codec, data_source, peer_selector, backend, protocol. The parser maps each snake_case role to its canonical PascalCase carrier on the IR (Backend, Index, Model, and so on). An unknown role identifier fails the proc-macro with a diagnostic listing the valid set.

What the derive emits

The bb::Concrete derive reads every #[depends(...)] attribute on the struct and emits a &'static [DependencyDecl] slice that surfaces in two places.

First, on the ConcreteComponent trait as the DEPENDENCIES associated constant:

// from bytesandbrains/bb-ir/src/component.rs:37-45
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct DependencyDecl {
    /// PascalCase role identifier. Plain string so `bb-ir` stays
    /// free of the `ComponentRole` enum.
    pub role: &'static str,

    /// Slot name in the compiler's binding spec.
    pub slot: &'static str,
}

Second, on the inventory carrier ConcreteComponentRegistration so the framework can recover the dep list from a TYPE_NAME lookup alone without re-traversing the user’s source:

// from bytesandbrains/bb-ir/src/registry.rs:7-21
pub struct ConcreteComponentRegistration {
    /// `ConcreteComponent::TYPE_NAME`.
    pub type_name: &'static str,
    /// Package origin.
    pub package: ComponentPackage,
    /// Monomorphized `T::serialize`.
    pub serialize_fn: SerializeFn,
    /// Monomorphized `T::restore`.
    pub restore_fn: RestoreFn,
    /// Downcasts `&dyn Any` → `&T::Config` then calls `T::new`.
    pub construct_fn: ConstructFn,
    /// Mirror of `ConcreteComponent::DEPENDENCIES`.
    pub dependencies: &'static [DependencyDecl],
}

A Component with no #[depends(...)] attribute gets the empty default slice. ConcreteComponent::DEPENDENCIES defaults to &[] so existing impls do not need to opt in. The walked example below prints the declared dependency list straight from the trait constant:

// from bytesandbrains/examples/component_with_dependency.rs:140-144
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,);
}

Compile-time enforcement

The compiler’s resolve_component_dependencies pass walks every bound concrete in the artifact’s BindingSpec, reads the declared DependencyDecls through the inventory carrier, and verifies each required slot is bound to a concrete whose role matches. The pass lives in bb-compiler/src/resolve_component_dependencies.rs and runs as part of the Compiler::compile pipeline.

The canonical wiring pairs an Index that declares a Backend dependency with the bind for that Backend. The chain uses the slot name the Component declared at its #[depends(...)] attribute:

// from bytesandbrains/examples/component_with_dependency.rs:155-158
let compiled = Compiler::new()
    .bind_index::<CountingIndex>("primary_index")
    .bind_backend::<CpuBackend>("compute")
    .compile(model)?;

CountingIndex is bound at slot "primary_index". It declared #[depends(backend = "compute")], so the pass demands a Backend concrete bound at slot "compute". The chain supplies CpuBackend at that slot. The pass returns Ok(()) and the compilation continues.

Two error shapes can come out of the pass.

The first is CompileError::UnboundDependency. This fires when the required slot is not bound at all. Drop the .bind_backend::<...> line and the compiler reports:

CountingIndex (bound at slot `primary_index`) requires a Backend at slot `compute`, but no such slot is bound

The second is CompileError::DependencyRoleMismatch. This fires when the slot exists but is bound to a Component whose role does not match. Bind "compute" to an Index instead of a Backend and the compiler reports:

CountingIndex (bound at slot `primary_index`) requires a Backend at slot `compute`, but the slot is bound to a IndexRuntime

Both errors include the component type name, the slot it was bound at, the required role and slot, and (for role mismatch) the role the bound slot actually provides. The variants live in bb-compiler/src/error.rs as CompileError::UnboundDependency and CompileError::DependencyRoleMismatch.

The pass also stamps each declared dependency onto every NodeProto the Component contributes to the graph. The stamp lands on metadata_props as ai.bytesandbrains.dep.<role> = "<slot>" so downstream passes and the runtime can recover the wiring from the IR alone. A Search op contributed by a CountingIndex that declared #[depends(backend = "compute")] lands with ai.bytesandbrains.dep.Backend = "compute" on its metadata.

Runtime resolution

Once the compiler has verified the wiring and the host has installed the compiled artifact, the bound concretes live in the engine’s slot registry keyed by the author-chosen slot name. Contracts that take a ctx: &mut RuntimeResourceRef<'_> parameter (every Contract method except the Backend per-op surface) reach their dependencies through ctx.dependency::<T>(slot).

The accessor lives in bb-runtime/src/runtime.rs:

// from bytesandbrains/bb-runtime/src/runtime.rs:342-354
pub fn dependency<T: 'static>(&self, slot_name: &str) -> Result<&T, DependencyError> {
    if self.components.for_slot(slot_name).is_none() {
        return Err(DependencyError::NotBound {
            slot: slot_name.to_string(),
        });
    }
    self.components
        .for_slot_as::<T>(slot_name)
        .ok_or_else(|| DependencyError::TypeMismatch {
            slot: slot_name.to_string(),
            expected: std::any::type_name::<T>(),
        })
}

The return type is Result<&T, DependencyError> where DependencyError has two variants:

// from bytesandbrains/bb-runtime/src/runtime.rs:279-295
pub enum DependencyError {
    /// No component is bound at the requested slot.
    NotBound {
        slot: String,
    },
    /// A component IS bound but downcasting to the requested
    /// type failed - the slot holds a different concrete than
    /// the caller expected.
    TypeMismatch {
        slot: String,
        expected: &'static str,
    },
}

Both errors are unreachable once Compiler::compile succeeds. The typed surface exists so test fixtures and introspection tooling can probe the slot registry without aborting the process. The canonical production call site uses .expect(...) with a message tying the expectation back to the compiler:

// from bytesandbrains/examples/component_with_dependency.rs:88-105
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");
    // ... use `backend` to compose the actual op ...
    ContractResponse::Now(Ok(Vec::new()))
}

The T in ctx.dependency::<T>(slot) is the concrete type the author bound at the slot. Resolution is a typed downcast against the slot registry’s &dyn ErasedComponent. There is no role-level type erasure on the call site: an Index that depends on a Backend reaches its bound CpuBackend directly, not through a dyn Backend trait object. The Backend’s per-op methods are then plain inherent calls on the resolved reference, which is what keeps the borrow-checker happy under the canonical let backend = ctx.dependency::<B>("...")?; backend.matmul(...)?; pattern documented in chapter 5.

Six of the seven method-style Contracts take a ctx: &mut RuntimeResourceRef<'_> parameter: Index, Aggregator, Model, Codec, DataSource, and PeerSelector. Backend is the one exception. Its user-facing per-op methods (add, mul, matmul, and the rest of the primitive surface) and its execute / dispatch entry points are ctx-free for borrow-checker reasons covered in Chapter 6. Backend is the terminal dependency in the injection chain. It is a leaf, not a parent: nothing else gets injected into it, so the absence of ctx.dependency on the Backend surface is not a gap.

The generic slot registry

Every bound Component lives in one map. The engine’s slot field is defined as:

// from bytesandbrains/bb-runtime/src/engine/core.rs:183
pub(crate) slots: HashMap<String, ComponentRef>,

The map is keyed by the author-chosen slot name. Backends, indexes, models, codecs, data sources, peer selectors, and any custom Component all share this one map. There is no per-role table, no backend-first dispatch hierarchy, no implicit default slot.

Node::slot(name) returns the ComponentRef for a slot or None, which lets host code and tests assert wiring shapes without unpacking the engine. The example uses it to confirm the install landed both bindings:

// from bytesandbrains/examples/component_with_dependency.rs:177-181
println!(
    "  Node installed. Engine slots: primary_index={:?}, compute={:?}",
    node.slot("primary_index").is_some(),
    node.slot("compute").is_some(),
);

At op-dispatch time the framework reaches into this map through ComponentsView::for_slot(name) for the erased lookup or for_slot_as::<T>(name) for the typed downcast. ctx.dependency is the public surface around the typed call: for_slot_as plus the typed DependencyError translation.

Why the generalization matters

The benefit of one generic map plus per-Component slot declarations shows up the moment a single Node needs two Backends. An Index that runs SIMD-heavy distance math and a Model that runs matmul-heavy forward passes can sit on the same Node, each bound to its own Backend:

// Two Backends on one Node, each Component reaches the one it needs.
let artifact = bb::Compiler::new()
    .bind_backend::<SimdCpu>("vector_ops")     // for the index's distance math
    .bind_backend::<GpuBackend>("matmul")      // for the model's matmul work
    .bind_index::<HnswIndex>("index")          // depends(backend = "vector_ops")
    .bind_model::<MyTransformer>("model")      // depends(backend = "matmul")
    .compile(module)?;

Each Component states the slot it depends on, the compiler verifies the chain, and the runtime resolves the typed reference at op-dispatch time. The #[depends(<role> = "<slot>")] attribute is the contract. Everything else is mechanical.

Where this lives

  • The DependencyDecl struct and its docs: bytesandbrains/bb-ir/src/component.rs.
  • The inventory carrier ConcreteComponentRegistration and its dependencies field: bytesandbrains/bb-ir/src/registry.rs.
  • The bb::Concrete derive that parses #[depends(...)]: bytesandbrains/bb-derive/src/lib.rs and bytesandbrains/bb-derive/src/parse.rs (parse_depends_attrs).
  • The compiler’s resolve_component_dependencies pass and its UnboundDependency / DependencyRoleMismatch errors: bytesandbrains/bb-compiler/src/resolve_component_dependencies.rs and bytesandbrains/bb-compiler/src/error.rs.
  • The companion validate_all_slots_bound pass: bytesandbrains/bb-compiler/src/validate_all_slots_bound.rs.
  • The IR metadata stamp ai.bytesandbrains.dep.<role>: bytesandbrains/bb-ir/src/keys.rs (stamp_dependency_metadata).
  • The runtime accessor ctx.dependency::<T>(slot) and the DependencyError enum: bytesandbrains/bb-runtime/src/runtime.rs.
  • The engine slot map field: bytesandbrains/bb-runtime/src/engine/core.rs.
  • The worked example used throughout this chapter: bytesandbrains/examples/component_with_dependency.rs.