Functional Weave
Code in Rust

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 80
  • explode_bom(lines ×7, BIKE, 10) → ×6 scrap on a sub-assembly line compounds: 22 wheels need 739.2 spokes, so 740 whole
  • explode_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>
linesBomLine[]every parent-component line; lines for items not under item are ignored
itemstringthe product to build
build_quantityinthow many of item to build, not negative
returnsBomRequirement[]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(…)
impl/rust.rs · 145 lines · open · raw

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
Download for Rust manufacturing.bom-explode-1.0.0-rust.fune · 25,554 bytes sha256 0a12b2d6f48c4b5164b78f6d53b9930cd40338c0ab778ea333948d03da8166b9

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md2,318
impl/python.py2,928
impl/rust.rs5,359
impl/typescript.ts2,910
vectors.json12,729