Functional Weave
Code in Rust

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 spoons
  • scale_recipe(ingredients ×3, 4, 10) → ×3 4 to 10: grams over 1000 become kilograms, millilitres become litres
  • scale_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.

pub fn scale_recipe(ingredients: &[RecipeQuantity], from_portions: i64, to_portions: i64) -> Vec<RecipeQuantity>
ingredientsRecipeQuantity[]the recipe as written; [] gives []
from_portionsintthe portions the recipe makes, 1 to 10000
to_portionsintthe portions wanted, 1 to 10000
returnsRecipeQuantity[]the same ingredients, in order, scaled and rounded

The type it declares, generated into your project

/// One ingredient and how much of it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecipeQuantity {
    pub name: String,
    /// decimal text such as "250" or "0.5"
    pub quantity: String,
    /// mg, g, kg, mL and L are tidied; each is rounded up; anything else keeps its unit
    pub unit: String,
}

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

fune!(hospitality.recipe-scale@^1);  // then call scale_recipe(…)
impl/rust.rs · 158 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.

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::units_convert::convert_units;  ← from units.convert ^1.0.0 · built alongside by fune

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()),
    }
}

fn metric(unit: &str) -> Option<(&'static str, &'static str)> {
    match unit {
        "mg" | "g" | "kg" => Some(("g", "kg")),
        "mL" | "L" => Some(("mL", "L")),
        _ => None,
    }
}

/// Digits and 10^scale of decimal text. The inputs are bounded (15 digits in,
/// 12 decimal places out of units.convert), so this always fits in i128.
fn parse_decimal(text: &str) -> (i128, i128) {
    let (whole, fraction) = text.split_once('.').unwrap_or((text, ""));
    let digits: i128 = format!("{}{}", whole, fraction).parse().expect("decimal digits");
    (digits, 10i128.pow(fraction.len() as u32))
}

fn half_up(numerator: i128, denominator: i128) -> i128 {
    (numerator * 2 + denominator) / (denominator * 2)
}

fn format_scaled(value: i128, decimals: u32) -> String {
    let unit = 10i128.pow(decimals);
    let whole = (value / unit).to_string();
    if decimals == 0 {
        return whole;
    }
    let fraction = format!("{:0width$}", value % unit, width = decimals as usize);
    let fraction = fraction.trim_end_matches('0');
    if fraction.is_empty() {
        whole
    } else {
        format!("{}.{}", whole, fraction)
    }
}

fn check_portions(name: &str, value: i64) {
    if !(1..=10000).contains(&value) {
        panic!("{} must be a whole number from 1 to 10000, received {}", name, value);
    }
}

/// 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.
///
/// # Panics
/// Panics on portions outside 1 to 10000, a malformed quantity or an empty unit.
pub fn scale_recipe(ingredients: &[RecipeQuantity], from_portions: i64, to_portions: i64) -> Vec<RecipeQuantity> {
    check_portions("fromPortions", from_portions);
    check_portions("toPortions", to_portions);
    let to = to_portions as i128;
    let from = from_portions as i128;
    ingredients
        .iter()
        .map(|ingredient| {
            let name = ingredient.name.clone();
            let quantity = ingredient.quantity.as_str();
            let unit = ingredient.unit.as_str();
            if !is_decimal(quantity) {
                panic!(
                    "quantity must be a non-negative decimal like \"12.5\", received \"{}\" for \"{}\"",
                    quantity, name
                );
            }
            let (whole, fraction) = quantity.split_once('.').unwrap_or((quantity, ""));
            let trimmed_fraction = fraction.trim_end_matches('0');
            let significant = format!("{}{}", whole, trimmed_fraction);
            if significant.trim_start_matches('0').len() > 15 || trimmed_fraction.len() > 15 {
                panic!(
                    "quantity \"{}\" for \"{}\" has too many digits: at most 15 significant digits and 15 decimal places",
                    quantity, name
                );
            }
            if unit.is_empty() {
                panic!("unit must not be empty for \"{}\"", name);
            }

            if let Some((small, large)) = metric(unit) {
                let (n, scale) = parse_decimal(&convert_units(quantity, unit, small, 12));
                let num = n * to;
                let den = scale * from;
                let decimals: u32 = if num < den {
                    2
                } else if num < den * 10 {
                    1
                } else {
                    0
                };
                let rounded = half_up(num * 10i128.pow(decimals), den);
                if decimals == 0 && rounded >= 1000 {
                    return RecipeQuantity {
                        name,
                        quantity: convert_units(&rounded.to_string(), small, large, 3),
                        unit: large.to_string(),
                    };
                }
                return RecipeQuantity { name, quantity: format_scaled(rounded, decimals), unit: small.to_string() };
            }
            // Trailing zeros dropped, so "1.000000000000000000000" stays small.
            let normal = if trimmed_fraction.is_empty() {
                whole.to_string()
            } else {
                format!("{}.{}", whole, trimmed_fraction)
            };
            let (n, scale) = parse_decimal(&normal);
            let num = n * to;
            let den = scale * from;
            if unit == "each" {
                return RecipeQuantity { name, quantity: ((num + den - 1) / den).to_string(), unit: unit.to_string() };
            }
            RecipeQuantity { name, quantity: format_scaled(half_up(num * 100, den), 2), unit: unit.to_string() }
        })
        .collect()
}

pub fn recipe_quantity_from_value(v: &Value) -> RecipeQuantity {
    RecipeQuantity {
        name: v.get("name").as_str().to_string(),
        quantity: v.get("quantity").as_str().to_string(),
        unit: v.get("unit").as_str().to_string(),
    }
}

pub fn recipe_quantity_to_value(q: &RecipeQuantity) -> Value {
    Value::obj(vec![
        ("name", Value::str(&q.name)),
        ("quantity", Value::str(&q.quantity)),
        ("unit", Value::str(&q.unit)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let ingredients: Vec<RecipeQuantity> = args[0].as_arr().iter().map(recipe_quantity_from_value).collect();
    Value::Arr(
        scale_recipe(&ingredients, args[1].as_i64(), args[2].as_i64())
            .iter()
            .map(recipe_quantity_to_value)
            .collect(),
    )
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, 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-scale
Download for Rust hospitality.recipe-scale-1.0.1-rust.fune · 17,398 bytes sha256 f6374d4fc817c16ad1f91819cbe54c6fe2b7c5db46485f8e078a99832bec053e

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

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,760
impl/python.py3,895
impl/rust.rs6,171
impl/typescript.ts3,699
vectors.json5,956