hospitality.recipe-scale
Scale a recipe to a new number of portions, tidying metric units (g to kg, mL to L) and rounding to kitchen precision.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 26 tests, run in TypeScript, Python and Rust.
What it does
Scales a recipe from the number of portions it makes to the number wanted, and writes each quantity the way a kitchen would weigh it.
## The rules
For example
scale_recipe(ingredients ×5, 4, 6)→ ×5 4 to 6 portions: grams, millilitres, eggs, a pinch-sized amount and spoonsscale_recipe(ingredients ×3, 4, 10)→ ×3 4 to 10: grams over 1000 become kilograms, millilitres become litresscale_recipe(ingredients ×2, 4, 2)→ ×2 4 to 2: kilograms under 1 become grams, litres become millilitres
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 scale_recipe(ingredients: Sequence[RecipeQuantity], from_portions: int, to_portions: int) -> List[RecipeQuantity]
| ingredients | RecipeQuantity[] | the recipe as written; [] gives [] |
| from_portions | int | the portions the recipe makes, 1 to 10000 |
| to_portions | int | the portions wanted, 1 to 10000 |
| returns | RecipeQuantity[] | the same ingredients, in order, scaled and rounded |
The type it declares, generated into your project
@dataclass(frozen=True)
class RecipeQuantity:
"""One ingredient and how much of it."""
name: str
#: decimal text such as "250" or "0.5"
quantity: str
#: mg, g, kg, mL and L are tidied; each is rounded up; anything else keeps its unit
unit: str
Your code names it in one line, in the file that uses it
from fune.hospitality.recipe_scale import scale_recipe # hospitality.recipe-scale@^1
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_scale_types import RecipeQuantity
from .units_convert import convert_units ← from units.convert ^1.0.0 · built alongside by fune
_DECIMAL = re.compile(r"[0-9]+(\.[0-9]+)?")
_METRIC = {
"mg": ("g", "kg"),
"g": ("g", "kg"),
"kg": ("g", "kg"),
"mL": ("mL", "L"),
"L": ("mL", "L"),
}
def _parse_decimal(text: str) -> Tuple[int, int]:
whole, _, fraction = text.partition(".")
return int(whole + fraction), 10 ** len(fraction)
def _half_up(numerator: int, denominator: int) -> int:
return (numerator * 2 + denominator) // (denominator * 2)
def _format_scaled(value: int, decimals: int) -> str:
unit = 10**decimals
whole = str(value // unit)
if decimals == 0:
return whole
fraction = str(value % unit).rjust(decimals, "0").rstrip("0")
return whole if fraction == "" else "%s.%s" % (whole, fraction)
def _check_portions(name: str, value: object) -> None:
if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 10000:
raise ValueError("%s must be a whole number from 1 to 10000, received %s" % (name, value))
def scale_recipe(ingredients: Sequence[RecipeQuantity], from_portions: int, to_portions: int) -> List[RecipeQuantity]:
"""Scale a recipe from one number of portions to another.
The scaling is exact (decimal text times a fraction), and each quantity is
rounded once, to the precision a kitchen weighs to: whole grams or
millilitres from 10 up, one decimal place from 1 to 10, two below 1.
Metric amounts move between g and kg, mL and L, at 1000. Counted items
(each) round up, since half an egg short is short. Any other unit keeps its
name and is rounded to two decimal places.
"""
_check_portions("fromPortions", from_portions)
_check_portions("toPortions", to_portions)
result: List[RecipeQuantity] = []
for ingredient in ingredients:
name, quantity, unit = ingredient.name, ingredient.quantity, ingredient.unit
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))
whole, _, fraction = quantity.partition(".")
trimmed_fraction = fraction.rstrip("0")
if len((whole + trimmed_fraction).lstrip("0")) > 15 or len(trimmed_fraction) > 15:
raise ValueError(
'quantity "%s" for "%s" has too many digits: at most 15 significant digits and 15 decimal places'
% (quantity, name)
)
if unit == "":
raise ValueError('unit must not be empty for "%s"' % (name,))
if unit in _METRIC:
small, large = _METRIC[unit]
n, scale = _parse_decimal(convert_units(quantity, unit, small, 12))
num = n * to_portions
den = scale * from_portions
decimals = 2 if num < den else 1 if num < den * 10 else 0
rounded = _half_up(num * 10**decimals, den)
if decimals == 0 and rounded >= 1000:
result.append(RecipeQuantity(name=name, quantity=convert_units(str(rounded), small, large, 3), unit=large))
else:
result.append(RecipeQuantity(name=name, quantity=_format_scaled(rounded, decimals), unit=small))
continue
# Trailing zeros dropped, so "1.000000000000000000000" stays small.
n, scale = _parse_decimal(whole if trimmed_fraction == "" else "%s.%s" % (whole, trimmed_fraction))
num = n * to_portions
den = scale * from_portions
if unit == "each":
result.append(RecipeQuantity(name=name, quantity=str((num + den - 1) // den), unit=unit))
else:
result.append(RecipeQuantity(name=name, quantity=_format_scaled(_half_up(num * 100, den), 2), unit=unit))
return resultInstall
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 hospitality.recipe-scale
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./hospitality.recipe-scale-1.0.1-python.fune, or fetch it from a terminal with fune pull hospitality.recipe-scale@1.0.1:python.
The whole function, every language, is one file too: hospitality.recipe-scale-1.0.1.fune, 25,349 bytes, sha256 f3dadaf13f24e70de045b41a408f23310643fa027ec7e91b2b134e6e012c2847. 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-scale
after — your function gets the result and the arguments, and returns the final result.
# fune: after hospitality.recipe-scale
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 units.convert in hospitality.recipe-scale
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-scale --steps.
# fune: step hospitality.recipe-scale 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 | |
|---|---|---|---|
| 4 to 6 portions: grams, millilitres, eggs, a pinch-sized amount and spoons | ingredients ×5, 4, 6 | → | ×5 |
| 4 to 10: grams over 1000 become kilograms, millilitres become litres | ingredients ×3, 4, 10 | → | ×3 |
| 4 to 2: kilograms under 1 become grams, litres become millilitres | ingredients ×2, 4, 2 | → | ×2 |
| 999.6 g rounds to a whole 1000 g and is written as 1 kg | ingredients ×1, 2, 3 | → | ×1 |
| under a gram keeps two decimal places | ingredients ×1, 4, 6 | → | ×1 |
| a third: whole grams from 10, one place from 1 to 10, two places for other units | ingredients ×3, 3, 1 | → | ×3 |
| a half rounds up, not to even: 2.25 g is 2.3 g | ingredients ×1, 4, 9 | → | ×1 |
| milligrams are tidied into grams | ingredients ×1, 1, 4 | → | ×1 |
| a small kilogram amount is written in grams | ingredients ×1, 1, 1 | → | ×1 |
| eggs that divide exactly stay exact | ingredients ×1, 4, 6 | → | ×1 |
Show the other 16 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| eggs always round up: 3 for 4 is 3.75 for 5, so 4 | ingredients ×1, 4, 5 | → | ×1 |
| nothing scales to nothing | ingredients ×1, 4, 6 | → | ×1 |
| imperial units keep their unit, to two places | ingredients ×2, 3, 4 | → | ×2 |
| an unrecognised unit is scaled as written | ingredients ×1, 4, 6 | → | ×1 |
| the same portions still tidies the unit | ingredients ×1, 4, 4 | → | ×1 |
| a banquet: 5 kg for one hundred times the portions | ingredients ×1, 1, 100 | → | ×1 |
| a few millilitres keep one place | ingredients ×1, 2, 3 | → | ×1 |
| trailing zeros are fine | ingredients ×1, 1, 2 | → | ×1 |
| an empty recipe scales to an empty recipe | , 2, 4 | → | |
| zero portions is an error | ingredients ×1, 0, 4 | → | error: fromPortions must be a whole number from 1 to 10000 |
| too many portions is an error | ingredients ×1, 4, 10,001 | → | error: toPortions must be a whole number from 1 to 10000 |
| a fraction as text is an error | ingredients ×1, 4, 6 | → | error: quantity must be a non-negative decimal |
| a negative quantity is an error | ingredients ×1, 4, 6 | → | error: quantity must be a non-negative decimal |
| an empty unit is an error | ingredients ×1, 4, 6 | → | error: unit must not be empty for "flour" |
| sixteen significant digits is an error | ingredients ×1, 4, 6 | → | error: has too many digits |
| a quantity with a trailing newline is an error | ingredients ×1, 4, 6 | → | error: quantity must be a non-negative decimal |
More from the author
The scaling itself is exact: the quantity is decimal text, multiplied by `toPortions / fromPortions` as a fraction. Then each quantity is rounded **once**, half-up:
| unit | rounded to | written in | |---|---|---| | mg, g, kg | whole grams from 10 g, 0.1 g from 1 to 10 g, 0.01 g below 1 g | g, or kg from 1000 g | | mL, L | the same steps in millilitres | mL, or L from 1000 mL | | each | always **up** to a whole number: half an egg short is short | each | | anything else (oz, lb, cup_us, tbsp, sprig...) | 2 decimal places | unchanged |
So 500 g for 4 is 1.25 kg for 10, 1.2 kg for 4 is 600 g for 2, and 666.4 g scaled by 1.5 is 999.6 g, which rounds to 1000 g and is written `1` kg. Metric amounts are converted with `units.convert`; the kilogram and litre forms keep up to 3 decimal places, which is whole grams and millilitres.
Imperial and cup measures stay in their unit rather than being converted to metric, because a cook reading a cup recipe expects cups back. Units the registry does not know (tbsp, sprig, pinch) are scaled as written.
## Limits and errors
- Portions are whole numbers from 1 to 10000. - A quantity is non-negative decimal text (`"0.5"`, not `"1/2"`), at most 15 significant digits and 15 decimal places. Trailing zeros are ignored. - The unit must not be empty.
## Not covered
Scaling is linear. Real kitchens don't always scale linearly: seasoning, leavening, and cooking times and pan sizes often need adjusting by hand when a recipe is multiplied many times over.
1.0.1 fixes Python accepting a trailing newline in quantity; adds tests.
Files
| Path | Bytes |
|---|---|
| README.md | 1,760 |
| impl/python.py | 3,895 |
| impl/rust.rs | 6,171 |
| impl/typescript.ts | 3,699 |
| vectors.json | 5,956 |