Skip to main content

bytesandbrains/
install.rs

1//! `bb::install` — single Node construction entry point.
2//!
3//! `Compiler::compile(module)` produces a `ModelProto` carrying:
4//! - `metadata_props["ai.bytesandbrains.compiled"] = "v1"` — passport;
5//!   install fails closed without it.
6//! - `metadata_props["ai.bytesandbrains.binding.<target>.<slot>"] =
7//!   "<role>|<TYPE_NAME>|<slot_id|-1>"` — one per `(target, slot)`.
8//! - `functions[]` — every partition's root function.
9//!
10//! `install(peer_id, addrs, model, targets, config)` verifies the
11//! passport, parses binding entries for each target, deduplicates
12//! shared bindings ([`InstallError::SlotBindingConflict`] on
13//! type/role mismatch), constructs each concrete exactly once via
14//! the inventory, and registers every target as an engine entry
15//! point. Bootstrap functions queue for serial firing in slice
16//! order.
17//!
18//! Slots whose concrete declares `type Config = ()` skip
19//! `Config::with(...)` — install supplies `&()` automatically.
20
21use std::any::Any;
22use std::collections::{HashMap, HashSet};
23
24use bb_ir::ids::PeerId;
25use bb_ir::keys::{
26    parse_binding_key, parse_binding_value, read_model_metadata, BINDING_KEY_PREFIX,
27    COMPILED_CURRENT_VERSION, COMPILED_KEY,
28};
29use bb_ir::proto::onnx::{FunctionProto, ModelProto};
30use bb_ir::registry::find_concrete_component;
31use bb_runtime::concrete::ComponentHandle;
32use bb_runtime::engine::dispatch_entry::FunctionKey;
33use bb_runtime::framework::Address;
34use bb_runtime::ids::ComponentRef;
35use bb_runtime::node::Node;
36use bb_runtime::registry::ComponentRole as R;
37use bb_runtime::registry::{dispatcher_for, roles_for_component};
38
39/// Per-deployment configuration supplied to [`install`]. Maps slot
40/// name → typed config value, downcast to the bound concrete's
41/// `<T as ConcreteComponent>::Config` before `T::new`. Slots with
42/// `type Config = ()` skip `with(...)`.
43pub struct Config {
44    configs: HashMap<String, Box<dyn Any>>,
45}
46
47impl Config {
48    /// Empty config bag.
49    pub fn new() -> Self {
50        Self {
51            configs: HashMap::new(),
52        }
53    }
54
55    /// Attach a typed config to `slot`. Downcast failures surface as
56    /// [`InstallError::ConfigTypeMismatch`].
57    pub fn with<C: Any + 'static>(mut self, slot: impl Into<String>, config: C) -> Self {
58        self.configs.insert(slot.into(), Box::new(config));
59        self
60    }
61}
62
63impl Default for Config {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69/// Errors surfaced by [`install`].
70#[derive(Debug)]
71pub enum InstallError {
72    /// No `ai.bytesandbrains.compiled` passport — caller installed a
73    /// bare `Module::build()` output instead of compiled artifact.
74    NotCompiled,
75
76    /// Passport version mismatch; recompile against this framework.
77    IncompatibleCompiledVersion {
78        /// Passport value read off the model.
79        got: String,
80        /// Passport value this framework version accepts.
81        expected: &'static str,
82    },
83
84    /// `target` names no function in `model.functions[]`.
85    UnknownTarget {
86        /// Target name the user passed.
87        target: String,
88        /// Functions the model carries (candidates).
89        available: Vec<String>,
90    },
91
92    /// `binding.<target>.<slot>` metadata entry failed to parse.
93    /// Indicates a hand-edited proto.
94    InvalidBindingTable {
95        /// Metadata key that failed to parse.
96        key: String,
97        /// Free-form parse error description.
98        detail: String,
99    },
100
101    /// Binding table references a `TYPE_NAME` not registered by any
102    /// `inventory::submit!` carrier in this binary. Link the type's
103    /// crate (and its `link_force()` helper, if any).
104    UnregisteredConcrete {
105        /// The unrecognized `TYPE_NAME`.
106        type_name: String,
107    },
108
109    /// Config missing for a slot whose concrete declares a non-unit
110    /// `Config` associated type.
111    MissingConfig {
112        /// Slot the binding spec declares.
113        slot: String,
114        /// `TYPE_NAME` of the concrete the spec says fills this slot.
115        type_name: String,
116    },
117
118    /// `Config::with(slot, ...)` value's runtime type doesn't match
119    /// the bound concrete's `Config` associated type.
120    ConfigTypeMismatch {
121        /// Slot the binding spec declares.
122        slot: String,
123        /// `TYPE_NAME` of the concrete the spec says fills this slot.
124        type_name: String,
125        /// Free-form detail from `ConstructError.detail`.
126        detail: String,
127    },
128
129    /// `T::new(&config)` returned an `Err` for this slot.
130    ConstructionFailed {
131        /// Slot the binding spec declares.
132        slot: String,
133        /// `TYPE_NAME` of the concrete that failed to construct.
134        type_name: String,
135        /// Stringified impl error.
136        detail: String,
137    },
138
139    /// Two or more targets bind the same slot to different
140    /// `(TYPE_NAME, role)` pairs. A shared slot must resolve to one
141    /// `ComponentRef`, so the bindings must agree.
142    SlotBindingConflict {
143        /// Slot name where the conflicting bindings collided.
144        slot: String,
145        /// `(target_name, type_name, role)` in call order.
146        conflicts: Vec<(String, String, String)>,
147    },
148
149    /// The `targets` slice was empty.
150    EmptyTargets,
151}
152
153impl std::fmt::Display for InstallError {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        match self {
156            Self::NotCompiled => write!(
157                f,
158                "install: ModelProto carries no `{COMPILED_KEY}` metadata stamp; \
159                 only `bb_compiler::Compiler::compile()` output may be installed",
160            ),
161            Self::IncompatibleCompiledVersion { got, expected } => write!(
162                f,
163                "install: ModelProto was compiled against `{got}` but this framework \
164                 requires `{expected}`; recompile",
165            ),
166            Self::UnknownTarget { target, available } => write!(
167                f,
168                "install: target function `{target}` not found in model.functions[]; \
169                 available targets: {available:?}",
170            ),
171            Self::InvalidBindingTable { key, detail } => write!(
172                f,
173                "install: binding metadata entry `{key}` is malformed: {detail}",
174            ),
175            Self::UnregisteredConcrete { type_name } => write!(
176                f,
177                "install: artifact references `{type_name}` but no \
178                 `inventory::submit!` carrier registers it in this binary",
179            ),
180            Self::MissingConfig { slot, type_name } => write!(
181                f,
182                "install: slot `{slot}` expected a config for `{type_name}` \
183                 (its `Config` associated type is not `()`); add \
184                 `Config::new().with(\"{slot}\", <config>)`",
185            ),
186            Self::ConfigTypeMismatch {
187                slot,
188                type_name,
189                detail,
190            } => write!(
191                f,
192                "install: slot `{slot}` received a config whose type does not \
193                 match `{type_name}`'s `Config` associated type ({detail})",
194            ),
195            Self::ConstructionFailed {
196                slot,
197                type_name,
198                detail,
199            } => write!(
200                f,
201                "install: `{type_name}::new` for slot `{slot}` returned an error: {detail}",
202            ),
203            Self::SlotBindingConflict { slot, conflicts } => {
204                write!(
205                    f,
206                    "install: slot `{slot}` has conflicting bindings across targets:",
207                )?;
208                for (target, type_name, role) in conflicts {
209                    write!(f, "\n  target `{target}` → `{type_name}` (role `{role}`)")?;
210                }
211                Ok(())
212            }
213            Self::EmptyTargets => write!(
214                f,
215                "install: targets slice is empty; supply at least one target name",
216            ),
217        }
218    }
219}
220
221impl std::error::Error for InstallError {}
222
223/// Single Node construction entry point. Verifies the passport,
224/// dedupes bindings across targets (rejecting `(TYPE_NAME, role)`
225/// conflicts with [`InstallError::SlotBindingConflict`]), constructs
226/// each concrete exactly once, and installs every target as a valid
227/// `deliver_event` / `invoke` destination.
228///
229/// `addresses` registers against `peer_id` in the engine's
230/// AddressBook; an empty vec skips self-registration. `targets`
231/// names target functions; the compiler's content-hash suffix
232/// (`<target>#<hash>`) is matched after exact-name. Bootstrap
233/// functions are recorded in slice order on
234/// `bootstrap_function_keys`; the host calls
235/// [`Node::run_bootstrap`] to drive each target serially before
236/// the body phase observes the first poll.
237pub fn install(
238    peer_id: PeerId,
239    addresses: Vec<Address>,
240    model: ModelProto,
241    targets: &[&str],
242    config: Config,
243) -> Result<Node, InstallError> {
244    // Anchor `inventory::submit!{}` blocks so linker DCE keeps them.
245    bb_ops::link_force();
246
247    if targets.is_empty() {
248        return Err(InstallError::EmptyTargets);
249    }
250
251    verify_compilation_stamp(&model)?;
252
253    let mut resolved_target_names: Vec<String> = Vec::with_capacity(targets.len());
254    let mut per_target_bindings: Vec<Vec<ResolvedBinding>> = Vec::with_capacity(targets.len());
255    for raw in targets {
256        let target_function = find_target(&model, raw)?;
257        let resolved_name = target_function.name.clone();
258        let bindings = parse_target_bindings(&model, &resolved_name)?;
259        resolved_target_names.push(resolved_name);
260        per_target_bindings.push(bindings);
261    }
262
263    let unified = dedupe_bindings_across_targets(&resolved_target_names, &per_target_bindings)?;
264
265    let mut node = Node::new(peer_id, addresses);
266    let mut registered_dispatchers: HashSet<(&'static str, &'static str)> = HashSet::new();
267    let unit_default: &dyn Any = &();
268
269    for (idx, binding) in unified.iter().enumerate() {
270        let next_cref = idx as u32;
271        let entry = find_concrete_component(&binding.type_name).ok_or_else(|| {
272            InstallError::UnregisteredConcrete {
273                type_name: binding.type_name.clone(),
274            }
275        })?;
276
277        // `type Config = ()` slots fall back to `&()`.
278        let supplied: &dyn Any = config
279            .configs
280            .get(&binding.slot)
281            .map(|b| b.as_ref())
282            .unwrap_or(unit_default);
283
284        let instance = (entry.construct_fn)(supplied).map_err(|e| {
285            if e.detail.starts_with("config type mismatch:") {
286                InstallError::ConfigTypeMismatch {
287                    slot: binding.slot.clone(),
288                    type_name: binding.type_name.clone(),
289                    detail: e.detail,
290                }
291            } else {
292                InstallError::ConstructionFailed {
293                    slot: binding.slot.clone(),
294                    type_name: binding.type_name.clone(),
295                    detail: e.detail,
296                }
297            }
298        })?;
299
300        register_dispatchers_for(
301            node.engine_install_handle(),
302            entry.type_name,
303            &mut registered_dispatchers,
304        );
305        let cref = ComponentRef::from(next_cref);
306        let instance_id = next_cref;
307        let engine = node.engine_install_handle();
308        engine.register_component(cref, instance);
309        engine.bind_slot(binding.slot.clone(), cref);
310        if let Some(slot_id) = binding.slot_id {
311            engine.bind_slot_id(slot_id, cref);
312            if let Some(role) = parse_role(&binding.role) {
313                engine.bind_slot_id_with_role(slot_id, role, cref);
314            }
315        }
316        stamp_component_roles(engine, entry.type_name, cref);
317
318        node.push_linked_component(ComponentHandle {
319            type_name: entry.type_name,
320            package: entry.package,
321            instance_id,
322            serialize_fn: entry.serialize_fn,
323            restore_fn: entry.restore_fn,
324            state_bytes: Vec::new(),
325        });
326    }
327
328    // Targets land as entry-point graphs; other functions enter the
329    // library so cross-module FunctionCalls resolve at dispatch.
330    install_targets(node.engine_install_handle(), &model, &resolved_target_names);
331    node.engine_install_handle().resolve_dispatch();
332
333    // Multi-target installs share one `Arc<ModelProto>`.
334    node.set_model(model);
335    for resolved in &resolved_target_names {
336        node.register_module(resolved.clone());
337    }
338
339    Ok(node)
340}
341
342/// Verify the compilation passport on a model.
343fn verify_compilation_stamp(model: &ModelProto) -> Result<(), InstallError> {
344    let Some(got) = read_model_metadata(model, COMPILED_KEY) else {
345        return Err(InstallError::NotCompiled);
346    };
347    if got != COMPILED_CURRENT_VERSION {
348        return Err(InstallError::IncompatibleCompiledVersion {
349            got: got.to_string(),
350            expected: COMPILED_CURRENT_VERSION,
351        });
352    }
353    Ok(())
354}
355
356/// Resolve `target` against `model.functions[]`. Exact match wins;
357/// otherwise accept `<target>#<hash>` (compiler's content-hash suffix).
358fn find_target<'a>(model: &'a ModelProto, target: &str) -> Result<&'a FunctionProto, InstallError> {
359    if let Some(exact) = model.functions.iter().find(|f| f.name == target) {
360        return Ok(exact);
361    }
362    let prefix = format!("{target}#");
363    if let Some(suffixed) = model.functions.iter().find(|f| f.name.starts_with(&prefix)) {
364        return Ok(suffixed);
365    }
366    let available = model
367        .functions
368        .iter()
369        .map(|f| f.name.clone())
370        .collect::<Vec<_>>();
371    Err(InstallError::UnknownTarget {
372        target: target.to_string(),
373        available,
374    })
375}
376
377/// One parsed binding entry.
378#[derive(Debug, Clone)]
379struct ResolvedBinding {
380    slot: String,
381    type_name: String,
382    /// Compiler-assigned slot id, or `None` for dep-only slots that
383    /// don't appear on any role NodeProto. Feeds the engine's
384    /// `slot_id_to_cref` lookup used by `resolve_dispatch`.
385    slot_id: Option<u32>,
386    /// Canonical role identifier (`"Backend"`, `"Index"`, …). Passed
387    /// to [`bb_runtime::engine::Engine::bind_slot_id_with_role`] so
388    /// `decode_typed_fill` can branch between framework-carrier
389    /// decode and backend-mediated tensor materialisation.
390    role: String,
391}
392
393/// Walk `model.metadata_props` for `binding.<target>.<slot>` entries
394/// matching the resolved `target_name` (content-hash suffix included).
395fn parse_target_bindings(
396    model: &ModelProto,
397    target_name: &str,
398) -> Result<Vec<ResolvedBinding>, InstallError> {
399    let mut out = Vec::new();
400    for entry in &model.metadata_props {
401        if !entry.key.starts_with(BINDING_KEY_PREFIX) {
402            continue;
403        }
404        let Some((target, slot)) = parse_binding_key(&entry.key) else {
405            return Err(InstallError::InvalidBindingTable {
406                key: entry.key.clone(),
407                detail: "key not in `ai.bytesandbrains.binding.<target>.<slot>` form".into(),
408            });
409        };
410        if target != target_name {
411            continue;
412        }
413        let Some((role, type_name, slot_id)) = parse_binding_value(&entry.value) else {
414            return Err(InstallError::InvalidBindingTable {
415                key: entry.key.clone(),
416                detail: format!(
417                    "value `{}` not in `<role>|<TYPE_NAME>|<slot_id|-1>` form",
418                    entry.value
419                ),
420            });
421        };
422        let slot_id = if slot_id < 0 {
423            None
424        } else {
425            Some(slot_id as u32)
426        };
427        out.push(ResolvedBinding {
428            slot: slot.to_string(),
429            type_name: type_name.to_string(),
430            slot_id,
431            role: role.to_string(),
432        });
433    }
434    Ok(out)
435}
436
437fn register_dispatchers_for(
438    engine: &mut bb_runtime::engine::Engine,
439    type_name: &'static str,
440    registered: &mut HashSet<(&'static str, &'static str)>,
441) {
442    for role in roles_for_component(type_name) {
443        let key = (type_name, role_as_str(role));
444        if !registered.insert(key) {
445            continue;
446        }
447        if let Some(register_fn) = dispatcher_for(type_name, role) {
448            register_fn(engine);
449        }
450    }
451}
452
453fn stamp_component_roles(
454    engine: &mut bb_runtime::engine::Engine,
455    type_name: &str,
456    cref: ComponentRef,
457) {
458    let roles: std::collections::HashSet<bb_runtime::registry::ComponentRole> =
459        roles_for_component(type_name).collect();
460    if !roles.is_empty() {
461        engine.set_component_roles(cref, roles);
462    }
463}
464
465/// Install targets as entry-point graphs and every other function
466/// into the library. Entry-point keys are passed to
467/// `install_function_library` so each target's GraphSlot lands with
468/// `is_entry_point = true`. Bootstrap functions for each target land
469/// on [`bb_runtime::engine::Engine::bootstrap_function_keys`] without
470/// arming the engine; the host calls `Node::run_bootstrap` to fire
471/// them serially in slice order before the body phase runs.
472fn install_targets(
473    engine: &mut bb_runtime::engine::Engine,
474    model: &ModelProto,
475    resolved_target_names: &[String],
476) {
477    let target_set: HashSet<&str> = resolved_target_names.iter().map(|s| s.as_str()).collect();
478    let mut entry_point_keys: Vec<FunctionKey> = Vec::with_capacity(resolved_target_names.len());
479    for resolved in resolved_target_names {
480        let Some(entry) = model
481            .functions
482            .iter()
483            .find(|f| f.name == *resolved)
484            .cloned()
485        else {
486            continue;
487        };
488        entry_point_keys.push((
489            entry.domain.clone(),
490            entry.name.clone(),
491            entry.overload.clone(),
492        ));
493        engine.install_graph(entry.name.clone(), entry);
494    }
495
496    let sub_functions: Vec<bb_ir::proto::onnx::FunctionProto> = model
497        .functions
498        .iter()
499        .filter(|f| !target_set.contains(f.name.as_str()))
500        .cloned()
501        .collect();
502    engine.install_function_library(&sub_functions, &entry_point_keys);
503}
504
505/// One slot's binding after cross-target dedup. Every entry gets
506/// exactly one `ComponentRef`, shared across targets referencing the
507/// same slot.
508#[derive(Debug, Clone)]
509struct UnifiedBinding {
510    /// Slot name (defaults to field name; overridden by
511    /// `#[bb::slot("custom")]`).
512    slot: String,
513    /// Inventory-registered `TYPE_NAME`. All contributing targets
514    /// agreed on this value (else `SlotBindingConflict`).
515    type_name: String,
516    /// Compiler-assigned slot id, or `None` for dep-only slots not
517    /// pinned to a role NodeProto. Compiler stamping guarantees the
518    /// same id across contributors so first-wins is safe.
519    slot_id: Option<u32>,
520    /// Canonical role identifier (`"Backend"`, `"Index"`, …).
521    role: String,
522}
523
524/// Walk per-target bindings, group by slot, dedup against
525/// `(type_name, role)`. Returns one [`UnifiedBinding`] per slot in
526/// first-seen order across the call-ordered targets.
527fn dedupe_bindings_across_targets(
528    target_names: &[String],
529    per_target_bindings: &[Vec<ResolvedBinding>],
530) -> Result<Vec<UnifiedBinding>, InstallError> {
531    // First-seen ordering keeps `ComponentRef` allocation stable
532    // across reruns of the same install.
533    let mut order: Vec<String> = Vec::new();
534    let mut by_slot: HashMap<String, UnifiedBinding> = HashMap::new();
535    let mut contributors: HashMap<String, Vec<(String, String, String)>> = HashMap::new();
536
537    for (target_idx, bindings) in per_target_bindings.iter().enumerate() {
538        let target_name = &target_names[target_idx];
539        for binding in bindings {
540            contributors.entry(binding.slot.clone()).or_default().push((
541                target_name.clone(),
542                binding.type_name.clone(),
543                binding.role.clone(),
544            ));
545            match by_slot.get(&binding.slot) {
546                None => {
547                    order.push(binding.slot.clone());
548                    by_slot.insert(
549                        binding.slot.clone(),
550                        UnifiedBinding {
551                            slot: binding.slot.clone(),
552                            type_name: binding.type_name.clone(),
553                            slot_id: binding.slot_id,
554                            role: binding.role.clone(),
555                        },
556                    );
557                }
558                Some(existing) => {
559                    if existing.type_name != binding.type_name || existing.role != binding.role {
560                        return Err(InstallError::SlotBindingConflict {
561                            slot: binding.slot.clone(),
562                            conflicts: contributors.remove(&binding.slot).unwrap_or_default(),
563                        });
564                    }
565                }
566            }
567        }
568    }
569
570    Ok(order
571        .into_iter()
572        .map(|slot| by_slot.remove(&slot).expect("slot inserted above"))
573        .collect())
574}
575
576fn role_as_str(role: bb_runtime::registry::ComponentRole) -> &'static str {
577    match role {
578        R::Index => "Index",
579        R::Aggregator => "Aggregator",
580        R::Model => "Model",
581        R::Codec => "Codec",
582        R::DataSource => "DataSource",
583        R::PeerSelector => "PeerSelector",
584        R::Backend => "Backend",
585        R::Protocol => "Protocol",
586    }
587}
588
589/// Parse the role identifier carried in the binding value. Returns
590/// `None` for unknown identifiers; callers skip
591/// `bind_slot_id_with_role` for forward-compat with future roles.
592fn parse_role(role: &str) -> Option<bb_runtime::registry::ComponentRole> {
593    match role {
594        "Index" => Some(R::Index),
595        "Aggregator" => Some(R::Aggregator),
596        "Model" => Some(R::Model),
597        "Codec" => Some(R::Codec),
598        "DataSource" => Some(R::DataSource),
599        "PeerSelector" => Some(R::PeerSelector),
600        "Backend" => Some(R::Backend),
601        "Protocol" => Some(R::Protocol),
602        _ => None,
603    }
604}
605