manufacturing.bom-explode
Explode a multi-level bill of materials into exact total quantities per component, with scrap and cycle checks.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 17 tests, run in TypeScript, Python and Rust.
What it does
Explodes a multi-level bill of materials (BOM): given the parent-component lines, a product and how many to build, it returns the total quantity of every sub-assembly and part needed, summed over every place each one is used.
need(component) += need(parent) x quantity x (1 + scrap)
For example
explode_bom(lines ×7, BIKE, 10)→ ×6 10 bikes: 1.5 m of tube at 10% scrap is 33/2 m, spokes at 5% scrap are 672, bolts used at two levels add up to 80explode_bom(lines ×7, BIKE, 10)→ ×6 scrap on a sub-assembly line compounds: 22 wheels need 739.2 spokes, so 740 wholeexplode_bom(lines ×7, WHEEL, 1)→ ×3 exploding one sub-assembly on its own
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn explode_bom(lines: &[BomLine], item: &str, build_quantity: i64) -> Vec<BomRequirement>
| lines | BomLine[] | every parent-component line; lines for items not under item are ignored |
| item | string | the product to build |
| build_quantity | int | how many of item to build, not negative |
| returns | BomRequirement[] | every item under item, by low-level code, then first appearance |
The types it declares, generated into your project
/// One line of a bill of materials: how much of a component one parent takes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BomLine {
pub parent: String,
pub component: String,
/// per one parent, more than zero: 3/2 for 1.5 m of tube
pub quantity: Rational,
/// component scrap added on top: 500 = 5% extra
pub scrap_basis_points: i64,
}
/// The total need for one item across every place it is used.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BomRequirement {
pub item: String,
/// low-level code: the deepest level it is used at; 1 is a direct component
pub level: i64,
/// exact total, scrap included
pub quantity: Rational,
/// quantity rounded up to whole units
pub whole_units: i64,
/// true when it has no bill of its own: bought in, not made
pub leaf: bool,
}
Your code names it in one line, in the file that uses it
fune!(manufacturing.bom-explode@^1); // then call explode_bom(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_rational::{
add_rational, multiply_rational, rational, rational_from_value, rational_to_integer, rational_to_value, Rational,
};
const MAX_SAFE: i64 = (1i64 << 53) - 1;
struct Entry {
name: String,
level: i64,
quantity: Rational,
}
struct Bom<'a> {
lines: Vec<(&'a BomLine, Rational)>,
}
impl<'a> Bom<'a> {
fn has_children(&self, item: &str) -> bool {
self.lines.iter().any(|(l, _)| l.parent == item)
}
fn walk(&self, parent: &str, need: &Rational, depth: i64, path: &mut Vec<String>, entries: &mut Vec<Entry>) {
for (line, factor) in self.lines.iter().filter(|(l, _)| l.parent == parent) {
if path.iter().any(|p| *p == line.component) {
let mut cycle = path.clone();
cycle.push(line.component.clone());
panic!("bill of materials has a cycle: {}", cycle.join(" -> "));
}
let quantity = multiply_rational(need, factor);
match entries.iter_mut().find(|e| e.name == line.component) {
None => entries.push(Entry {
name: line.component.clone(),
level: depth,
quantity,
}),
Some(e) => {
e.level = e.level.max(depth);
e.quantity = add_rational(&e.quantity, &quantity);
}
}
if self.has_children(&line.component) {
path.push(line.component.clone());
self.walk(&line.component, &quantity, depth + 1, path, entries);
path.pop();
}
}
}
}
/// Total quantity of every item under `item`, summed over every place it is
/// used, in exact fractions. Walks the BOM depth first, carrying the path so a
/// loop is reported by name instead of recursing for ever.
///
/// # Panics
/// Panics on a negative build quantity, a quantity that is not positive,
/// negative scrap, a product with no lines, a cycle, or a total beyond 2^53 - 1.
pub fn explode_bom(lines: &[BomLine], item: &str, build_quantity: i64) -> Vec<BomRequirement> {
if !(0..=MAX_SAFE).contains(&build_quantity) {
panic!("buildQuantity must be a whole number, not negative, received {}", build_quantity);
}
let mut bom = Bom { lines: Vec::new() };
for line in lines {
let q = rational(line.quantity.numerator, line.quantity.denominator);
if q.numerator <= 0 {
panic!("quantity of \"{}\" in \"{}\" must be greater than zero", line.component, line.parent);
}
if !(0..=MAX_SAFE).contains(&line.scrap_basis_points) {
panic!(
"scrapBasisPoints of \"{}\" in \"{}\" must be a whole number, not negative, received {}",
line.component, line.parent, line.scrap_basis_points
);
}
let factor = multiply_rational(&q, &rational(10000 + line.scrap_basis_points, 10000));
bom.lines.push((line, factor));
}
if !bom.has_children(item) {
panic!("\"{}\" has no bill of materials", item);
}
let mut entries: Vec<Entry> = Vec::new();
let mut path = vec![item.to_string()];
bom.walk(item, &rational(build_quantity, 1), 1, &mut path, &mut entries);
// A stable sort on level keeps first-appearance order within a level.
let mut order: Vec<usize> = (0..entries.len()).collect();
order.sort_by_key(|&i| entries[i].level);
order
.into_iter()
.map(|i| {
let e = &entries[i];
BomRequirement {
item: e.name.clone(),
level: e.level,
quantity: e.quantity,
whole_units: rational_to_integer(&e.quantity, "up"),
leaf: !bom.has_children(&e.name),
}
})
.collect()
}
pub fn bom_line_from_value(v: &Value) -> BomLine {
BomLine {
parent: v.get("parent").as_str().to_string(),
component: v.get("component").as_str().to_string(),
quantity: rational_from_value(v.get("quantity")),
scrap_basis_points: v.get("scrapBasisPoints").as_i64(),
}
}
pub fn bom_requirement_to_value(r: &BomRequirement) -> Value {
Value::obj(vec![
("item", Value::str(&r.item)),
("level", Value::Int(r.level)),
("quantity", rational_to_value(&r.quantity)),
("wholeUnits", Value::Int(r.whole_units)),
("leaf", Value::Bool(r.leaf)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
if let Value::Float(f) = &args[2] {
panic!("buildQuantity must be a whole number, not negative, received {}", f);
}
let lines: Vec<BomLine> = args[0]
.as_arr()
.iter()
.map(|v| {
if let Value::Float(f) = v.get("scrapBasisPoints") {
panic!(
"scrapBasisPoints of \"{}\" in \"{}\" must be a whole number, not negative, received {}",
v.get("component").as_str(),
v.get("parent").as_str(),
f
);
}
bom_line_from_value(v)
})
.collect();
Value::Arr(
explode_bom(&lines, args[1].as_str(), args[2].as_i64())
.iter()
.map(bom_requirement_to_value)
.collect(),
)
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add manufacturing.bom-explode
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./manufacturing.bom-explode-1.0.0-rust.fune, or fetch it from a terminal with fune pull manufacturing.bom-explode@1.0.0:rust.
The whole function, every language, is one file too: manufacturing.bom-explode-1.0.0.fune, 31,594 bytes, sha256 358f6d5d5064497212fb69f10e1c6ef4db3a6504b0a9922c6fdc680011c3115e. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before manufacturing.bom-explode
after — your function gets the result and the arguments, and returns the final result.
// fune: after manufacturing.bom-explode
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace math.rational in manufacturing.bom-explode
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show manufacturing.bom-explode --steps.
// fune: step manufacturing.bom-explode after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| 10 bikes: 1.5 m of tube at 10% scrap is 33/2 m, spokes at 5% scrap are 672, bolts used at two levels add up to 80 | lines ×7, BIKE, 10 | → | ×6 |
| scrap on a sub-assembly line compounds: 22 wheels need 739.2 spokes, so 740 whole | lines ×7, BIKE, 10 | → | ×6 |
| exploding one sub-assembly on its own | lines ×7, WHEEL, 1 | → | ×3 |
| a build quantity of zero needs nothing, but still lists every item | lines ×7, FRAME, 0 | → | ×1 |
| a tenth of a sheet per case, ten coatings per sheet: 3 cases need exactly 3 coatings (floating point gives 3.0000000000000004, so 4) | lines ×2, CASE, 3 | → | ×2 |
| a part used directly and three levels down takes the deeper low-level code and is listed last | lines ×4, TOP, 5 | → | ×3 |
| two lines for the same component are added together | lines ×2, TOP, 1 | → | ×1 |
| a loop elsewhere in the lines is not walked | lines ×3, TOP, 1 | → | ×1 |
| a loop through three items is an error naming the loop | lines ×3, A, 1 | → | error: bill of materials has a cycle: A -> B -> C -> A |
| an item that contains itself is an error | lines ×1, A, 1 | → | error: bill of materials has a cycle: A -> A |
Show the other 7 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a loop below the product is an error | lines ×3, TOP, 1 | → | error: bill of materials has a cycle: TOP -> A -> B -> A |
| a product with no lines is an error | lines ×7, PUMP, 1 | → | error: "PUMP" has no bill of materials |
| a zero quantity is an error | lines ×1, TOP, 1 | → | error: quantity of "X" in "TOP" must be greater than zero |
| a negative quantity is an error | lines ×1, TOP, 1 | → | error: quantity of "X" in "TOP" must be greater than zero |
| negative scrap is an error | lines ×1, TOP, 1 | → | error: scrapBasisPoints of "X" in "TOP" must be a whole number, not negative |
| a negative build quantity is an error | lines ×7, BIKE, -1 | → | error: buildQuantity must be a whole number, not negative |
| a fractional build quantity is an error | lines ×7, BIKE, 1.5 | → | error: buildQuantity must be a whole number, not negative |
More from the author
**Exact quantities.** A line's quantity is a `Rational` (from `math.rational`), so 1.5 m of tube is `3/2` and a tenth of a sheet is `1/10`, and totals are exact fractions. Nothing is rounded on the way down: three cases that each take a tenth of a sheet, with ten coatings per sheet, need exactly 3 coatings, where floating point says 3.0000000000000004 and a ceiling makes it 4. `wholeUnits` is the exact total rounded up, once, for items counted in whole units; use `quantity` for anything measured (metres, kilograms).
**Scrap.** `scrapBasisPoints` is component scrap as most ERP systems define it: an allowance added to the line's quantity (500 = 5% more), applied to that line only. Scrap on a sub-assembly line compounds into everything below it, because the extra sub-assemblies need their own parts.
**Levels.** Each item's `level` is its low-level code: the deepest level at which it appears (a direct component of the product is level 1). A part used both directly and inside a sub-assembly gets the deeper level, which is the order MRP must plan in so all of a part's demand is known before it is planned. Results are ordered by level, then by where the item first appears in a depth-first walk of the lines in the order given.
**Cycles are errors.** A BOM in which an item contains itself, directly or through other items, has no finite explosion; the error names the loop (`bill of materials has a cycle: A -> B -> C -> A`). Only the part of the BOM under `item` is walked, so a loop elsewhere in the lines is not reported.
**Other rules.** Several lines for the same parent and component are added together. `leaf` is true for items with no lines of their own (bought in). The product itself is not in the result. A product with no lines, a quantity that is not positive, negative scrap or a negative build quantity is an error. Build quantity 0 gives every item with a zero quantity. Totals are exact fractions and must stay within 2^53 - 1 when reduced (`math.rational`'s range).
Files
| Path | Bytes |
|---|---|
| README.md | 2,318 |
| impl/python.py | 2,928 |
| impl/rust.rs | 5,359 |
| impl/typescript.ts | 2,910 |
| vectors.json | 12,729 |