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.
def explode_bom(lines: Sequence[BomLine], item: str, build_quantity: int) -> List[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
@dataclass(frozen=True)
class BomLine:
"""One line of a bill of materials: how much of a component one parent takes."""
parent: str
component: str
#: per one parent, more than zero: 3/2 for 1.5 m of tube
quantity: Rational
#: component scrap added on top: 500 = 5% extra
scrap_basis_points: int
@dataclass(frozen=True)
class BomRequirement:
"""The total need for one item across every place it is used."""
item: str
#: low-level code: the deepest level it is used at; 1 is a direct component
level: int
#: exact total, scrap included
quantity: Rational
#: quantity rounded up to whole units
whole_units: int
#: true when it has no bill of its own: bought in, not made
leaf: bool
Your code names it in one line, in the file that uses it
from fune.manufacturing.bom_explode import explode_bom # 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.
from typing import Dict, List, Sequence, Tuple
from .manufacturing_bom_explode_types import BomLine, BomRequirement
from .math_rational import Rational, add_rational, multiply_rational, rational, rational_to_integer ← from math.rational ^1.0.0 · built alongside by fune
MAX_SAFE = 2**53 - 1
def explode_bom(lines: Sequence[BomLine], item: str, build_quantity: int) -> List[BomRequirement]:
"""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.
"""
if isinstance(build_quantity, bool) or not isinstance(build_quantity, int) or build_quantity < 0 or build_quantity > MAX_SAFE:
raise ValueError("buildQuantity must be a whole number, not negative, received %r" % (build_quantity,))
children: Dict[str, List[Tuple[BomLine, Rational]]] = {}
for line in lines:
q = rational(line.quantity.numerator, line.quantity.denominator)
if q.numerator <= 0:
raise ValueError('quantity of "%s" in "%s" must be greater than zero' % (line.component, line.parent))
scrap = line.scrap_basis_points
if isinstance(scrap, bool) or not isinstance(scrap, int) or scrap < 0 or scrap > MAX_SAFE:
raise ValueError(
'scrapBasisPoints of "%s" in "%s" must be a whole number, not negative, received %r'
% (line.component, line.parent, scrap)
)
factor = multiply_rational(q, rational(10000 + scrap, 10000))
children.setdefault(line.parent, []).append((line, factor))
if item not in children:
raise ValueError('"%s" has no bill of materials' % (item,))
# name -> [first-seen index, level, quantity]; dicts keep insertion order.
entries: Dict[str, list] = {}
def walk(parent: str, need: Rational, depth: int, path: List[str]) -> None:
for line, factor in children.get(parent, []):
if line.component in path:
raise ValueError("bill of materials has a cycle: %s" % " -> ".join(path + [line.component]))
quantity = multiply_rational(need, factor)
entry = entries.get(line.component)
if entry is None:
entries[line.component] = [len(entries), depth, quantity]
else:
entry[1] = max(entry[1], depth)
entry[2] = add_rational(entry[2], quantity)
if line.component in children:
walk(line.component, quantity, depth + 1, path + [line.component])
walk(item, rational(build_quantity, 1), 1, [item])
ordered = sorted(entries.items(), key=lambda kv: (kv[1][1], kv[1][0]))
return [
BomRequirement(
item=name,
level=e[1],
quantity=e[2],
whole_units=rational_to_integer(e[2], "up"),
leaf=name not in children,
)
for name, e in ordered
]Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python 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 Python implementation. Install it without the registry with fune add ./manufacturing.bom-explode-1.0.0-python.fune, or fetch it from a terminal with fune pull manufacturing.bom-explode@1.0.0:python.
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 |