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
explodeBom(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 80explodeBom(lines ×7, BIKE, 10)→ ×6 scrap on a sub-assembly line compounds: 22 wheels need 739.2 spokes, so 740 wholeexplodeBom(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.
export function explodeBom(lines: readonly BomLine[], item: string, buildQuantity: number): readonly BomRequirement[]
| lines | BomLine[] | every parent-component line; lines for items not under item are ignored |
| item | string | the product to build |
| buildQuantity | 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. */
export interface BomLine {
readonly parent: string;
readonly component: string;
/** per one parent, more than zero: 3/2 for 1.5 m of tube */
readonly quantity: Rational;
/** component scrap added on top: 500 = 5% extra */
readonly scrapBasisPoints: number;
}
/** The total need for one item across every place it is used. */
export interface BomRequirement {
readonly item: string;
/** low-level code: the deepest level it is used at; 1 is a direct component */
readonly level: number;
/** exact total, scrap included */
readonly quantity: Rational;
/** quantity rounded up to whole units */
readonly wholeUnits: number;
/** true when it has no bill of its own: bought in, not made */
readonly leaf: boolean;
}
Your code names it in one line, in the file that uses it
import { explodeBom } from "#fune/manufacturing.bom-explode@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type Rational, addRational, multiplyRational, rational, rationalToInteger } from "./math_rational.ts"; ← from math.rational ^1.0.0 · built alongside by fune
import { type BomLine, type BomRequirement } from "./manufacturing_bom_explode_types.ts";
interface Entry {
index: number;
level: number;
quantity: Rational;
}
/**
* 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.
*/
export function explodeBom(lines: readonly BomLine[], item: string, buildQuantity: number): readonly BomRequirement[] {
if (!Number.isSafeInteger(buildQuantity) || buildQuantity < 0) {
throw new RangeError(`buildQuantity must be a whole number, not negative, received ${buildQuantity}`);
}
const children = new Map<string, { line: BomLine; factor: Rational }[]>();
for (const line of lines) {
const q = rational(line.quantity.numerator, line.quantity.denominator);
if (q.numerator <= 0) {
throw new RangeError(`quantity of "${line.component}" in "${line.parent}" must be greater than zero`);
}
if (!Number.isSafeInteger(line.scrapBasisPoints) || line.scrapBasisPoints < 0) {
throw new RangeError(
`scrapBasisPoints of "${line.component}" in "${line.parent}" must be a whole number, not negative, received ${line.scrapBasisPoints}`,
);
}
const factor = multiplyRational(q, rational(10000 + line.scrapBasisPoints, 10000));
const list = children.get(line.parent) ?? [];
list.push({ line, factor });
children.set(line.parent, list);
}
if (!children.has(item)) {
throw new RangeError(`"${item}" has no bill of materials`);
}
const entries = new Map<string, Entry>();
const walk = (parent: string, need: Rational, depth: number, path: string[]): void => {
for (const { line, factor } of children.get(parent) ?? []) {
if (path.includes(line.component)) {
throw new RangeError(`bill of materials has a cycle: ${[...path, line.component].join(" -> ")}`);
}
const quantity = multiplyRational(need, factor);
const entry = entries.get(line.component);
if (entry === undefined) {
entries.set(line.component, { index: entries.size, level: depth, quantity });
} else {
entry.level = Math.max(entry.level, depth);
entry.quantity = addRational(entry.quantity, quantity);
}
if (children.has(line.component)) {
walk(line.component, quantity, depth + 1, [...path, line.component]);
}
}
};
walk(item, rational(buildQuantity, 1), 1, [item]);
return [...entries.entries()]
.sort(([, a], [, b]) => a.level - b.level || a.index - b.index)
.map(([name, e]) => ({
item: name,
level: e.level,
quantity: e.quantity,
wholeUnits: rationalToInteger(e.quantity, "up"),
leaf: !children.has(name),
}));
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript 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. 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 TypeScript implementation. Install it without the registry with fune add ./manufacturing.bom-explode-1.0.0-typescript.fune, or fetch it from a terminal with fune pull manufacturing.bom-explode@1.0.0:typescript.
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 |