Functional Weave
Code in Python

hospitality.recipe-cost

Cost a recipe and each portion from ingredient quantities, pack sizes and pack prices, allowing for trim and waste.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 25 tests, run in TypeScript, Python and Rust.

What it does

Costs a recipe the way a kitchen costing sheet does: what each ingredient costs in the recipe, the total, and the cost of one portion.

For each ingredient:

For example

  • recipe_cost(ingredients ×4, 8, GBP) → lines ×4, total £3.93, portions 8, per portion £0.49 a batter for 8: grams from a kilo bag, a whole pack, eggs by the dozen, millilitres from a litre bottle
  • recipe_cost(ingredients ×1, 4, GBP) → lines ×1, total £0.23, portions 4, per portion £0.06 yield grosses up: 400 g peeled at 80% yield means 500 g bought, not 320 g
  • recipe_cost(ingredients ×1, 2, GBP) → lines ×1, total £1.50, portions 2, per portion £0.75 imperial: 8 oz from a 1 lb pack

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 recipe_cost(ingredients: Sequence[RecipeIngredient], portions: int, currency: str) -> RecipeCost
ingredientsRecipeIngredient[]at least one
portionsinthow many portions the recipe makes, at least 1
currencystringthe currency every pack price is in
returnsRecipeCost

The types it declares, generated into your project

@dataclass(frozen=True)
class RecipeIngredient:
    """One line of a recipe, and how the ingredient is bought."""

    name: str
    #: usable amount the recipe needs, as decimal text such as "250"
    quantity: str
    #: a units.convert symbol ("g", "kg", "mL", "L", "oz", "lb"...) or "each"
    unit: str
    #: share of what is bought that is usable after trim and waste; 10000 = none lost
    yield_basis_points: int
    #: how much one pack holds, as decimal text, at most 6 decimal places
    pack_size: str
    #: unit of packSize, the same dimension as unit
    pack_unit: str
    #: the price of one pack
    pack_price: Money

@dataclass(frozen=True)
class RecipeCostLine:
    """What one ingredient costs in the recipe."""

    name: str
    cost: Money

@dataclass(frozen=True)
class RecipeCost:
    """The recipe's cost, line by line and per portion."""

    lines: List[RecipeCostLine]
    #: the lines added up
    total: Money
    portions: int
    #: total / portions, rounded half-up
    per_portion: Money

Your code names it in one line, in the file that uses it

from fune.hospitality.recipe_cost import recipe_cost  # hospitality.recipe-cost@^1
impl/python.py · 98 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.

import re
from typing import List, Sequence, Tuple

from .hospitality_recipe_cost_types import RecipeCost, RecipeCostLine, RecipeIngredient
from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import money  ← from money.amount ^1.0.0 · built alongside by fune
from .money_sum import sum_money  ← from money.sum ^1.0.0 · built alongside by fune
from .units_convert import convert_units  ← from units.convert ^1.0.0 · built alongside by fune

_DECIMAL = re.compile(r"[0-9]+(\.[0-9]+)?")
_I128_MAX = (1 << 127) - 1
# Money must survive a JavaScript number in the TypeScript port.
_MAX_SAFE = (1 << 53) - 1
# Quantities are carried to 9 decimal places of the pack unit: a microgram of
# a kilogram pack, far below anything a kitchen weighs.
_QUANTITY_DECIMALS = 9


def _parse_decimal(text: str) -> Tuple[int, int]:
    whole, _, fraction = text.partition(".")
    return int(whole + fraction), len(fraction)


def _whole(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def _line_cost(ingredient: RecipeIngredient) -> int:
    name = ingredient.name
    quantity = ingredient.quantity
    if not isinstance(quantity, str) or not _DECIMAL.fullmatch(quantity):
        raise ValueError('quantity must be a non-negative decimal like "12.5", received "%s" for "%s"' % (quantity, name))
    pack_size = ingredient.pack_size
    ok = isinstance(pack_size, str) and _DECIMAL.fullmatch(pack_size) is not None
    pack_digits, pack_scale = _parse_decimal(pack_size) if ok else (0, 0)
    if not ok or pack_digits == 0 or pack_scale > 6 or len(str(pack_digits)) > 15:
        raise ValueError(
            'packSize must be a positive decimal with at most 6 decimal places and 15 digits, received "%s" for "%s"'
            % (pack_size, name)
        )
    y = ingredient.yield_basis_points
    if not _whole(y) or y < 1 or y > 10000:
        raise ValueError('yieldBasisPoints must be from 1 to 10000, received %s for "%s"' % (y, name))
    price = ingredient.pack_price
    if price.minor < 0:
        raise ValueError('packPrice must not be negative, received %d for "%s"' % (price.minor, name))

    unit, pack_unit = ingredient.unit, ingredient.pack_unit
    if unit == "each" or pack_unit == "each":
        if unit != pack_unit:
            raise ValueError('cannot convert %s to %s: "each" only converts to "each"' % (unit, pack_unit))
        if _parse_decimal(quantity)[1] > _QUANTITY_DECIMALS:
            raise ValueError(
                'quantity may have at most %d decimal places, received "%s" for "%s"' % (_QUANTITY_DECIMALS, quantity, name)
            )
        in_pack_units = quantity
    else:
        in_pack_units = convert_units(quantity, unit, pack_unit, _QUANTITY_DECIMALS)

    # cost = price x (quantity / packSize) / yield, in one exact division.
    qty_digits, qty_scale = _parse_decimal(in_pack_units)
    numerator = price.minor * qty_digits * 10000
    denominator = pack_digits * y
    if pack_scale >= qty_scale:
        numerator *= 10 ** (pack_scale - qty_scale)
    else:
        denominator *= 10 ** (qty_scale - pack_scale)
    if numerator > _I128_MAX or denominator > _I128_MAX:
        raise ValueError('the cost of "%s" is too large to compute exactly' % (name,))
    rounded = (numerator * 2 + denominator) // (denominator * 2)
    if rounded > _MAX_SAFE:
        raise ValueError('the cost of "%s" is too large to compute exactly' % (name,))
    return rounded


def recipe_cost(ingredients: Sequence[RecipeIngredient], portions: int, currency: str) -> RecipeCost:
    """The cost of a recipe, each ingredient and each portion.

    Each ingredient's quantity is converted to the unit its pack is sold in
    (250 g of a 1.5 kg bag), grossed up for trim and waste (400 g of peeled
    carrots needs 500 g bought at an 80% yield), and priced as that share of
    the pack, rounded half-up to the minor unit. The total is the sum of those
    line costs, so the costing sheet adds up.
    """
    if len(ingredients) == 0:
        raise ValueError("a recipe needs at least one ingredient")
    if not _whole(portions) or portions < 1:
        raise ValueError("portions must be at least 1, received %s" % (portions,))
    lines: List[RecipeCostLine] = [
        RecipeCostLine(name=i.name, cost=money(_line_cost(i), i.pack_price.currency)) for i in ingredients
    ]
    total = sum_money([line.cost for line in lines], currency)
    return RecipeCost(
        lines=lines,
        total=total,
        portions=portions,
        per_portion=money(round_div(total.minor, portions, "half-up"), currency),
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 dependencies, 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 hospitality.recipe-cost
Download for Python hospitality.recipe-cost-1.0.1-python.fune · 21,776 bytes sha256 47942cdd96af7179de06b41ed4e0147313ada896ba1c66f0014fc9432f10e9f0

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./hospitality.recipe-cost-1.0.1-python.fune, or fetch it from a terminal with fune pull hospitality.recipe-cost@1.0.1:python.

The whole function, every language, is one file too: hospitality.recipe-cost-1.0.1.fune, 33,471 bytes, sha256 46e7b8e9d2f1a0c701b9782c76ba36ce439ddd530eb27edc42f0b66d35beea5d. 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 hospitality.recipe-cost

after — your function gets the result and the arguments, and returns the final result.

# fune: after hospitality.recipe-cost

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.round-div in hospitality.recipe-cost
# fune: replace money.amount in hospitality.recipe-cost
# fune: replace money.sum in hospitality.recipe-cost
# fune: replace units.convert in hospitality.recipe-cost

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 hospitality.recipe-cost --steps.

# fune: step hospitality.recipe-cost 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
a batter for 8: grams from a kilo bag, a whole pack, eggs by the dozen, millilitres from a litre bottle ingredients ×4, 8, GBP → lines ×4, total £3.93, portions 8, per portion £0.49
yield grosses up: 400 g peeled at 80% yield means 500 g bought, not 320 g ingredients ×1, 4, GBP → lines ×1, total £0.23, portions 4, per portion £0.06
imperial: 8 oz from a 1 lb pack ingredients ×1, 2, GBP → lines ×1, total £1.50, portions 2, per portion £0.75
metric recipe, imperial pack: 100 g of a 1 lb pack is 0.220462262 lb ingredients ×1, 1, GBP → lines ×1, total £1.00, portions 1, per portion £1.00
a pinch costs less than a penny and rounds to nothing ingredients ×1, 1, GBP → lines ×1, total £0.00, portions 1, per portion £0.00
saffron by the gram ingredients ×1, 4, GBP → lines ×1, total £3.20, portions 4, per portion £0.80
a zero quantity costs nothing ingredients ×1, 1, GBP → lines ×1, total £0.00, portions 1, per portion £0.00
the portion cost rounds half-up: 0.10 over 4 is 0.025 ingredients ×1, 4, GBP → lines ×1, total £0.10, portions 4, per portion £0.03
a fractional pack size: 0.75 L bottle of wine ingredients ×1, 1, GBP → lines ×1, total £1.80, portions 1, per portion £1.80
half an egg ingredients ×1, 1, GBP → lines ×1, total £0.17, portions 1, per portion £0.17
Show the other 15 tests
CaseArgumentsExpected
euros ingredients ×1, 2, EUR → lines ×1, total €0.65, portions 2, per portion €0.33
mass priced by volume is an error ingredients ×1, 1, GBP → error: cannot convert g (mass) to L (volume)
each against grams is an error ingredients ×1, 1, GBP → error: cannot convert each to g
an unknown unit is an error ingredients ×1, 1, GBP → error: unknown unit "cup"
a zero yield is an error ingredients ×1, 1, GBP → error: yieldBasisPoints must be from 1 to 10000
a yield over 100% is an error ingredients ×1, 1, GBP → error: yieldBasisPoints must be from 1 to 10000
no portions is an error ingredients ×4, 0, GBP → error: portions must be at least 1
an empty recipe is an error , 1, GBP → error: a recipe needs at least one ingredient
a negative quantity is an error ingredients ×1, 1, GBP → error: quantity must be a non-negative decimal
a zero pack size is an error ingredients ×1, 1, GBP → error: packSize must be a positive decimal
a pack size with 7 decimal places is an error ingredients ×1, 1, GBP → error: packSize must be a positive decimal
a negative pack price is an error ingredients ×1, 1, GBP → error: packPrice must not be negative
a pack priced in another currency is an error ingredients ×1, 1, GBP → error: currency mismatch
a quantity with a trailing newline is an error ingredients ×1, 1, GBP → error: quantity must be a non-negative decimal
a pack size with a trailing newline is an error ingredients ×1, 1, GBP → error: packSize must be a positive decimal

More from the author

1. **Convert** the quantity the recipe needs into the unit the pack is sold in, with `units.convert` (250 g of a 1.5 kg bag is 0.25 kg). Count items use the unit `each`, which only converts to `each` (3 eggs from a tray of 12). 2. **Gross up for yield.** `quantity` is the usable amount the recipe needs after trimming and peeling. `yieldBasisPoints` is the usable share of what is bought: 400 g of peeled carrots at an 8000 (80%) yield means buying 500 g. A common mistake multiplies by the yield instead, costing 320 g. 3. **Price it** as that share of the pack price, in one exact division, rounded half-up to the minor unit.

The total is the sum of the rounded line costs, so the sheet adds up, and the portion cost is the total divided by `portions`, rounded half-up.

## Precision

Quantities and pack sizes are decimal text (`"0.75"`), never floats. The converted quantity is carried to 9 decimal places of the pack unit, a microgram of a kilogram pack, and everything after that is exact integer arithmetic. Pack sizes may have at most 6 decimal places and 15 digits. An ingredient whose cost cannot be held exactly (beyond 2^53 minor units) is an error rather than a rounded guess.

A line under half a penny (a pinch of salt) costs 0. Kitchens that want those counted usually add a "sundries" line as a fixed amount.

## Errors

- Units must be ones `units.convert` knows (`g`, `kg`, `mL`, `L`, `oz`, `lb`, `floz_imp`...) or `each`, and a quantity and its pack must be the same dimension: oil costed in grams but bought by the litre needs a density, which this does not guess. - `yieldBasisPoints` is 1 to 10000, `portions` at least 1, pack prices not negative, and every pack price in `currency`.

1.0.1 fixes Python accepting a trailing newline in quantity and packSize; adds tests.

Files

PathBytes
README.md2,007
impl/python.py4,432
impl/rust.rs7,104
impl/typescript.ts4,153
vectors.json9,733