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
DependencyDeclstruct and its docs:bytesandbrains/bb-ir/src/component.rs. - The inventory carrier
ConcreteComponentRegistrationand itsdependenciesfield:bytesandbrains/bb-ir/src/registry.rs. - The
bb::Concretederive that parses#[depends(...)]:bytesandbrains/bb-derive/src/lib.rsandbytesandbrains/bb-derive/src/parse.rs(parse_depends_attrs). - The compiler’s
resolve_component_dependenciespass and itsUnboundDependency/DependencyRoleMismatcherrors:bytesandbrains/bb-compiler/src/resolve_component_dependencies.rsandbytesandbrains/bb-compiler/src/error.rs. - The companion
validate_all_slots_boundpass: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 theDependencyErrorenum: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.