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 bottlerecipe_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 grecipe_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.
pub fn recipe_cost(ingredients: &[RecipeIngredient], portions: i64, currency: &str) -> RecipeCost
| ingredients | RecipeIngredient[] | at least one |
| portions | int | how many portions the recipe makes, at least 1 |
| currency | string | the currency every pack price is in |
| returns | RecipeCost |
The types it declares, generated into your project
/// One line of a recipe, and how the ingredient is bought.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecipeIngredient {
pub name: String,
/// usable amount the recipe needs, as decimal text such as "250"
pub quantity: String,
/// a units.convert symbol ("g", "kg", "mL", "L", "oz", "lb"...) or "each"
pub unit: String,
/// share of what is bought that is usable after trim and waste; 10000 = none lost
pub yield_basis_points: i64,
/// how much one pack holds, as decimal text, at most 6 decimal places
pub pack_size: String,
/// unit of packSize, the same dimension as unit
pub pack_unit: String,
/// the price of one pack
pub pack_price: Money,
}
/// What one ingredient costs in the recipe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecipeCostLine {
pub name: String,
pub cost: Money,
}
/// The recipe's cost, line by line and per portion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecipeCost {
pub lines: Vec<RecipeCostLine>,
/// the lines added up
pub total: Money,
pub portions: i64,
/// total / portions, rounded half-up
pub per_portion: Money,
}
Your code names it in one line, in the file that uses it
fune!(hospitality.recipe-cost@^1); // then call recipe_cost(…)
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_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
use super::money_sum::sum_money; ← from money.sum ^1.0.0 · built alongside by fune
use super::units_convert::convert_units; ← from units.convert ^1.0.0 · built alongside by fune
// Quantities are carried to 9 decimal places of the pack unit: a microgram of
// a kilogram pack, far below anything a kitchen weighs.
const QUANTITY_DECIMALS: i64 = 9;
fn is_decimal(text: &str) -> bool {
let mut parts = text.splitn(2, '.');
let whole = parts.next().unwrap_or("");
let ok_whole = !whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit());
match parts.next() {
None => ok_whole,
Some(f) => ok_whole && !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()),
}
}
/// Digits and scale of plain decimal text; None if the digits overflow.
fn parse_decimal(text: &str) -> Option<(i128, u32, usize)> {
let (whole, fraction) = match text.split_once('.') {
Some((w, f)) => (w, f),
None => (text, ""),
};
let digits = format!("{}{}", whole, fraction);
let trimmed = digits.trim_start_matches('0');
let significant = if trimmed.is_empty() { 1 } else { trimmed.len() };
if significant > 36 {
return None;
}
Some((digits.parse::<i128>().ok()?, fraction.len() as u32, significant))
}
fn pow10(n: u32) -> Option<i128> {
10i128.checked_pow(n)
}
fn line_cost(ingredient: &RecipeIngredient) -> i64 {
let name = &ingredient.name;
let quantity = ingredient.quantity.as_str();
if !is_decimal(quantity) {
panic!(
"quantity must be a non-negative decimal like \"12.5\", received \"{}\" for \"{}\"",
quantity, name
);
}
let pack = if is_decimal(&ingredient.pack_size) { parse_decimal(&ingredient.pack_size) } else { None };
let (pack_digits, pack_scale) = match pack {
Some((d, s, sig)) if d != 0 && s <= 6 && sig <= 15 => (d, s),
_ => panic!(
"packSize must be a positive decimal with at most 6 decimal places and 15 digits, received \"{}\" for \"{}\"",
ingredient.pack_size, name
),
};
let y = ingredient.yield_basis_points;
if !(1..=10000).contains(&y) {
panic!("yieldBasisPoints must be from 1 to 10000, received {} for \"{}\"", y, name);
}
let price = &ingredient.pack_price;
if price.minor < 0 {
panic!("packPrice must not be negative, received {} for \"{}\"", price.minor, name);
}
let unit = ingredient.unit.as_str();
let pack_unit = ingredient.pack_unit.as_str();
let in_pack_units = if unit == "each" || pack_unit == "each" {
if unit != pack_unit {
panic!("cannot convert {} to {}: \"each\" only converts to \"each\"", unit, pack_unit);
}
let scale = quantity.split_once('.').map_or(0, |(_, f)| f.len()) as i64;
if scale > QUANTITY_DECIMALS {
panic!(
"quantity may have at most {} decimal places, received \"{}\" for \"{}\"",
QUANTITY_DECIMALS, quantity, name
);
}
quantity.to_string()
} else {
convert_units(quantity, unit, pack_unit, QUANTITY_DECIMALS)
};
// cost = price x (quantity / packSize) / yield, in one exact division.
let too_large = || -> ! { panic!("the cost of \"{}\" is too large to compute exactly", name) };
let (qty_digits, qty_scale, _) = parse_decimal(&in_pack_units).unwrap_or_else(|| too_large());
let mut numerator = (price.minor as i128)
.checked_mul(qty_digits)
.and_then(|n| n.checked_mul(10000))
.unwrap_or_else(|| too_large());
let mut denominator = pack_digits.checked_mul(y as i128).unwrap_or_else(|| too_large());
if pack_scale >= qty_scale {
numerator = pow10(pack_scale - qty_scale)
.and_then(|p| numerator.checked_mul(p))
.unwrap_or_else(|| too_large());
} else {
denominator = pow10(qty_scale - pack_scale)
.and_then(|p| denominator.checked_mul(p))
.unwrap_or_else(|| too_large());
}
// Half-up on non-negative values: floor((2n + d) / 2d), without the
// doubling that could overflow near the i128 limit.
let quotient = numerator / denominator;
let remainder = numerator % denominator;
let rounded = if remainder >= denominator - remainder { quotient + 1 } else { quotient };
// Money must survive a JavaScript number in the TypeScript port.
if rounded > (1i128 << 53) - 1 {
too_large();
}
rounded as i64
}
/// 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.
///
/// # Panics
/// Panics on an empty recipe, bad portions, a malformed ingredient, units that
/// do not convert, or mixed currencies.
pub fn recipe_cost(ingredients: &[RecipeIngredient], portions: i64, currency: &str) -> RecipeCost {
if ingredients.is_empty() {
panic!("a recipe needs at least one ingredient");
}
if portions < 1 {
panic!("portions must be at least 1, received {}", portions);
}
let lines: Vec<RecipeCostLine> = ingredients
.iter()
.map(|i| RecipeCostLine { name: i.name.clone(), cost: money(line_cost(i), &i.pack_price.currency) })
.collect();
let costs: Vec<Money> = lines.iter().map(|l| l.cost.clone()).collect();
let total = sum_money(&costs, currency);
let per_portion = money(round_div(total.minor, portions, "half-up"), currency);
RecipeCost { lines, total, portions, per_portion }
}
pub fn recipe_ingredient_from_value(v: &Value) -> RecipeIngredient {
RecipeIngredient {
name: v.get("name").as_str().to_string(),
quantity: v.get("quantity").as_str().to_string(),
unit: v.get("unit").as_str().to_string(),
yield_basis_points: v.get("yieldBasisPoints").as_i64(),
pack_size: v.get("packSize").as_str().to_string(),
pack_unit: v.get("packUnit").as_str().to_string(),
pack_price: money_from_value(v.get("packPrice")),
}
}
pub fn recipe_cost_to_value(c: &RecipeCost) -> Value {
Value::obj(vec![
(
"lines",
Value::Arr(
c.lines
.iter()
.map(|l| Value::obj(vec![("name", Value::str(&l.name)), ("cost", money_to_value(&l.cost))]))
.collect(),
),
),
("total", money_to_value(&c.total)),
("portions", Value::Int(c.portions)),
("perPortion", money_to_value(&c.per_portion)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let ingredients: Vec<RecipeIngredient> = args[0].as_arr().iter().map(recipe_ingredient_from_value).collect();
recipe_cost_to_value(&recipe_cost(&ingredients, args[1].as_i64(), args[2].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, 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 hospitality.recipe-cost
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./hospitality.recipe-cost-1.0.1-rust.fune, or fetch it from a terminal with fune pull hospitality.recipe-cost@1.0.1:rust.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 2,007 |
| impl/python.py | 4,432 |
| impl/rust.rs | 7,104 |
| impl/typescript.ts | 4,153 |
| vectors.json | 9,733 |