Skip to main content

bb_compiler/
validate_bootstrap_composition.rs

1//! Structural check for the bootstrap-as-function composition tree.
2//!
3//! Every Module's bootstrap recording emits its own FunctionProto
4//! stamped `MODULE_PHASE_BOOTSTRAP`. A parent Module that composes
5//! children via `ModuleCall::bootstrap(g)` records a CALL NodeProto
6//! into its own bootstrap pointing at the child's
7//! `"<child>__bootstrap"` FunctionProto. This pass walks the call
8//! graph rooted at the target's bootstrap and surfaces:
9//!
10//! - [`CompileError::BootstrapCompositionGap`] when a Call points at a
11//!   bootstrap function name that has no matching FunctionProto in the
12//!   model — the engine's FunctionCall dispatch would refuse to seed a
13//!   CallContext for a missing target.
14//! - [`CompileError::BootstrapCompositionCycle`] when the function-call
15//!   graph is not a DAG. Bootstrap completion is one-shot; a cycle in
16//!   the composition tree wedges the engine in `bootstrap_pending`
17//!   forever.
18//!
19//! Runs at the top level of the compile pipeline — before inlining or
20//! partitioning can rearrange the function table — so the error
21//! surfaces against the recorded composition shape the author wrote.
22
23use std::collections::{HashMap, HashSet};
24
25use bb_ir::keys::{read_function_module_phase, MODULE_PHASE_BOOTSTRAP};
26use bb_ir::proto::onnx::ModelProto;
27
28use crate::error::CompileError;
29
30/// CALL NodeProto domain produced by `Graph::with_function`. Same
31/// constant as `verify_no_dangling_calls` / `inline_for_partition` use;
32/// duplicated here so this pass stays self-contained.
33const MODULE_CALL_DOMAIN: &str = "ai.bytesandbrains.module";
34
35/// Walk the bootstrap-call graph rooted at `<target>__bootstrap` and
36/// verify every reachable Call resolves to an existing FunctionProto.
37///
38/// `target_name` is the root function's name (i.e. the body half of
39/// the top-level Module). Models whose root Module has no bootstrap
40/// recording are no-ops — the trait's default `fn bootstrap(&self,
41/// _g: &mut Graph) {}` emits an empty bootstrap that
42/// `Module::build` drops on the floor.
43pub fn validate_bootstrap_composition(
44    model: &ModelProto,
45    target_name: &str,
46) -> Result<(), CompileError> {
47    let by_name: HashMap<&str, &bb_ir::proto::onnx::FunctionProto> = model
48        .functions
49        .iter()
50        .map(|f| (f.name.as_str(), f))
51        .collect();
52
53    let root_bootstrap = format!("{target_name}__bootstrap");
54    let Some(root_fn) = by_name.get(root_bootstrap.as_str()) else {
55        return Ok(());
56    };
57    if read_function_module_phase(root_fn) != Some(MODULE_PHASE_BOOTSTRAP) {
58        // Name collides with the bootstrap convention but the phase
59        // stamp is missing — treat as not-a-bootstrap rather than
60        // misclassify.
61        return Ok(());
62    }
63
64    // Tri-coloured DFS over the bootstrap call graph. `gray` carries
65    // the active recursion stack so a back-edge surfaces the
66    // participating function names in cycle order.
67    let mut black: HashSet<String> = HashSet::new();
68    let mut gray: Vec<String> = Vec::new();
69    walk(&root_bootstrap, &by_name, &mut gray, &mut black)
70}
71
72fn walk(
73    name: &str,
74    by_name: &HashMap<&str, &bb_ir::proto::onnx::FunctionProto>,
75    gray: &mut Vec<String>,
76    black: &mut HashSet<String>,
77) -> Result<(), CompileError> {
78    if black.contains(name) {
79        return Ok(());
80    }
81    if let Some(pos) = gray.iter().position(|n| n == name) {
82        let mut involves: Vec<String> = gray[pos..].to_vec();
83        involves.push(name.to_string());
84        return Err(CompileError::BootstrapCompositionCycle { involves });
85    }
86
87    let Some(function) = by_name.get(name) else {
88        // Caller is responsible for confirming `name` resolves before
89        // calling `walk`; an unresolved name here means a child Call
90        // pointed at a missing bootstrap.
91        return Err(CompileError::BootstrapCompositionGap {
92            caller: gray.last().cloned().unwrap_or_else(|| name.to_string()),
93            target: name.to_string(),
94        });
95    };
96
97    gray.push(name.to_string());
98    for node in &function.node {
99        if node.domain != MODULE_CALL_DOMAIN {
100            continue;
101        }
102        let target = node.op_type.as_str();
103        if target.is_empty() {
104            continue;
105        }
106        if !by_name.contains_key(target) {
107            return Err(CompileError::BootstrapCompositionGap {
108                caller: name.to_string(),
109                target: target.to_string(),
110            });
111        }
112        // Only descend into callees that themselves carry the
113        // bootstrap-phase stamp. A Call from a bootstrap into a body
114        // function is a recording bug the DSL never emits, but
115        // ignoring non-bootstrap callees here keeps the walk
116        // bootstrap-scoped — body-only call cycles surface in
117        // `verify_no_dangling_calls` / the per-graph cycle pass.
118        let callee = by_name[target];
119        if read_function_module_phase(callee) == Some(MODULE_PHASE_BOOTSTRAP) {
120            walk(target, by_name, gray, black)?;
121        }
122    }
123    gray.pop();
124    black.insert(name.to_string());
125    Ok(())
126}
127