recipe_scale
Scale a recipe's ingredient list by servings, by a factor or to a target amount of one ingredient, in exact fractions: reads quantities like 1 1/2, ½, 2-3 and units (cups, tbsp, tsp, fl oz, g, kg, oz, lb, ml, l, pinches, cans), gives the exact scaled amount and a measurable one (cups to 1/4 or 1/3, tbsp to 1/2, tsp to 1/8, grams whole), moves small cup amounts to spoons and flags fractional eggs.
Install
/tools install recipe_scale /tools install recipe_scale@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · recipe_scale 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool recipe_scale; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
ingredients | array of ['string', 'object'] | yes | Lines such as "1 1/2 cups flour" or objects {quantity, unit, name}. items ≥ 1; items ≤ 500 | |
servings | object | no | ||
factor | number | string | no | 1.5, "3/2" or "150%" | |
target | object | no | ||
round | boolean | no | true | |
convert_units | boolean | no | true | Move small cup amounts to tbsp, 3 tsp to 1 tbsp, quarter cups of tbsp to cups |
Input schema (JSON)
{
"type": "object",
"required": [
"ingredients"
],
"additionalProperties": false,
"properties": {
"ingredients": {
"type": "array",
"minItems": 1,
"maxItems": 500,
"items": {
"type": [
"string",
"object"
],
"additionalProperties": false,
"properties": {
"quantity": {
"type": [
"number",
"string"
]
},
"unit": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"description": "Lines such as \"1 1/2 cups flour\" or objects {quantity, unit, name}"
},
"servings": {
"type": "object",
"additionalProperties": false,
"required": [
"from",
"to"
],
"properties": {
"from": {
"type": [
"number",
"string"
]
},
"to": {
"type": [
"number",
"string"
]
}
}
},
"factor": {
"type": [
"number",
"string"
],
"description": "1.5, \"3/2\" or \"150%\""
},
"target": {
"type": "object",
"additionalProperties": false,
"required": [
"ingredient",
"quantity"
],
"properties": {
"ingredient": {
"type": "string",
"description": "Part of the ingredient's name"
},
"quantity": {
"type": [
"number",
"string"
]
},
"unit": {
"type": "string",
"description": "Default: the recipe's unit for that ingredient"
}
}
},
"round": {
"type": "boolean",
"default": true
},
"convert_units": {
"type": "boolean",
"default": true,
"description": "Move small cup amounts to tbsp, 3 tsp to 1 tbsp, quarter cups of tbsp to cups"
}
}
}
Example
Input
{
"ingredients": [
"1 1/2 cups flour",
"3 eggs",
"½ tsp salt"
],
"servings": {
"from": 4,
"to": 6
}
}
Output (stdout)
{
"factor": "1 1/2",
"ingredients": [
{
"scaled": "2 1/4 cups flour"
},
{
"scaled": "5 eggs",
"flag": "eggs"
},
{
"scaled": "3/4 tsp salt"
}
]
}
Files
The command the install card registers is python3 recipe_scale.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
recipe_scale.py · sha256 cc115b7c7b6a59f2179ed98fb1ab33aa7e6b6c8e0c116a052b5f4b8ae57db456
#!/usr/bin/env python3
"""Scale a recipe's ingredient list by servings, by a factor, or to a target amount of one
ingredient, with exact fractions and kitchen-friendly rounding.
Input (JSON on stdin):
ingredients ["1 1/2 cups flour", "2-3 tbsp sugar", "3 eggs", "½ tsp salt", "salt to taste", ...]
or objects {"quantity": "1 1/2", "unit": "cup", "name": "flour"}
servings {"from": 4, "to": 6} one of servings, factor, target
factor "3/2", 1.5 or "150%"
target {"ingredient": "flour", "quantity": "3", "unit": "cups"} (unit optional: the same as
the recipe's; volume to volume and mass to mass convert with US customary/SI factors)
round true (default): cups to 1/4 or 1/3, tbsp to 1/2, tsp to 1/8, g and ml whole, eggs whole
convert_units true (default): small cups to tbsp, large tsp to tbsp, many tbsp to cups
"""
import json
import re
import sys
from fractions import Fraction
UNICODE = {"½": Fraction(1, 2), "⅓": Fraction(1, 3), "⅔": Fraction(2, 3), "¼": Fraction(1, 4), "¾": Fraction(3, 4),
"⅕": Fraction(1, 5), "⅖": Fraction(2, 5), "⅗": Fraction(3, 5), "⅘": Fraction(4, 5), "⅙": Fraction(1, 6),
"⅚": Fraction(5, 6), "⅛": Fraction(1, 8), "⅜": Fraction(3, 8), "⅝": Fraction(5, 8), "⅞": Fraction(7, 8)}
# unit: (canonical, family, factor to the family base: tsp for US volume, ml for metric volume, g for mass)
UNITS = {
"cup": ("cup", "volume", Fraction(48)), "cups": ("cup", "volume", Fraction(48)), "c": ("cup", "volume", Fraction(48)),
"tablespoon": ("tbsp", "volume", Fraction(3)), "tablespoons": ("tbsp", "volume", Fraction(3)), "tbsp": ("tbsp", "volume", Fraction(3)),
"tbs": ("tbsp", "volume", Fraction(3)), "tbl": ("tbsp", "volume", Fraction(3)), "T": ("tbsp", "volume", Fraction(3)),
"teaspoon": ("tsp", "volume", Fraction(1)), "teaspoons": ("tsp", "volume", Fraction(1)), "tsp": ("tsp", "volume", Fraction(1)),
"t": ("tsp", "volume", Fraction(1)),
"fl oz": ("fl oz", "volume", Fraction(6)), "fl. oz": ("fl oz", "volume", Fraction(6)), "fluid ounce": ("fl oz", "volume", Fraction(6)),
"fluid ounces": ("fl oz", "volume", Fraction(6)),
"pint": ("pint", "volume", Fraction(96)), "pints": ("pint", "volume", Fraction(96)), "quart": ("quart", "volume", Fraction(192)),
"quarts": ("quart", "volume", Fraction(192)),
"ml": ("ml", "metric_volume", Fraction(1)), "milliliter": ("ml", "metric_volume", Fraction(1)), "milliliters": ("ml", "metric_volume", Fraction(1)),
"millilitre": ("ml", "metric_volume", Fraction(1)), "millilitres": ("ml", "metric_volume", Fraction(1)),
"cl": ("cl", "metric_volume", Fraction(10)), "dl": ("dl", "metric_volume", Fraction(100)),
"l": ("l", "metric_volume", Fraction(1000)), "liter": ("l", "metric_volume", Fraction(1000)), "liters": ("l", "metric_volume", Fraction(1000)),
"litre": ("l", "metric_volume", Fraction(1000)), "litres": ("l", "metric_volume", Fraction(1000)),
"g": ("g", "mass", Fraction(1)), "gram": ("g", "mass", Fraction(1)), "grams": ("g", "mass", Fraction(1)),
"kg": ("kg", "mass", Fraction(1000)), "kilogram": ("kg", "mass", Fraction(1000)), "kilograms": ("kg", "mass", Fraction(1000)),
"mg": ("mg", "mass", Fraction(1, 1000)),
"oz": ("oz", "mass", Fraction(28349523125, 10 ** 9)), "ounce": ("oz", "mass", Fraction(28349523125, 10 ** 9)),
"ounces": ("oz", "mass", Fraction(28349523125, 10 ** 9)),
"lb": ("lb", "mass", Fraction(45359237, 10 ** 5)), "lbs": ("lb", "mass", Fraction(45359237, 10 ** 5)),
"pound": ("lb", "mass", Fraction(45359237, 10 ** 5)), "pounds": ("lb", "mass", Fraction(45359237, 10 ** 5)),
"pinch": ("pinch", "other", None), "pinches": ("pinch", "other", None), "dash": ("dash", "other", None), "dashes": ("dash", "other", None),
"clove": ("clove", "count", None), "cloves": ("clove", "count", None), "can": ("can", "count", None), "cans": ("can", "count", None),
"stick": ("stick", "count", None), "sticks": ("stick", "count", None), "slice": ("slice", "count", None), "slices": ("slice", "count", None),
"piece": ("piece", "count", None), "pieces": ("piece", "count", None), "package": ("package", "count", None),
"packages": ("package", "count", None), "sprig": ("sprig", "count", None), "sprigs": ("sprig", "count", None),
"bunch": ("bunch", "count", None), "handful": ("handful", "other", None),
}
PLURAL = {"cup": "cups", "pint": "pints", "quart": "quarts", "pinch": "pinches", "dash": "dashes", "clove": "cloves",
"can": "cans", "stick": "sticks", "slice": "slices", "piece": "pieces", "package": "packages", "sprig": "sprigs"}
US_ML_PER_TSP = Fraction(473176473, 96 * 10 ** 6) # US cup 236.5882365 mL / 48 (NIST Handbook 44)
NUM = r"(?:\d+\s+\d+/\d+|\d+\s*[½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]|\d+/\d+|\d+(?:\.\d+)?|\.\d+|[½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])"
LINE = re.compile(rf"^\s*(?P<q1>{NUM})(?:\s*(?:-|–|—|to)\s*(?P<q2>{NUM}))?\s*(?P<rest>.*)$")
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def parse_number(text, field):
text = text.strip()
try:
m = re.fullmatch(r"(\d+)\s+(\d+)/(\d+)", text)
if m:
return int(m.group(1)) + Fraction(int(m.group(2)), int(m.group(3)))
m = re.fullmatch(r"(\d+)\s*([½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])", text)
if m:
return int(m.group(1)) + UNICODE[m.group(2)]
if text in UNICODE:
return UNICODE[text]
return Fraction(text)
except (ValueError, ZeroDivisionError):
fail(f"{field}: cannot read the quantity {text!r}")
def mixed(x):
if x.denominator == 1:
return str(x.numerator)
whole, rest = divmod(x.numerator, x.denominator)
frac = f"{rest}/{x.denominator}"
return f"{whole} {frac}" if whole else frac
def split_unit(rest):
"""(unit key or None, name) from the text after the quantity."""
text = rest.strip()
lowered = text.lower()
for key in sorted(UNITS, key=len, reverse=True):
if key == "T" or key == "t":
continue
if lowered.startswith(key) and (len(text) == len(key) or not text[len(key)].isalpha()):
name = text[len(key):].lstrip(" .")
if name.lower().startswith("of "):
name = name[3:]
return key, name.strip()
for key in ("T", "t"):
if text.startswith(key + " "):
return key, text[2:].strip()
return None, text
def parse_line(item, i):
field = f"ingredients[{i}]"
if isinstance(item, dict):
extra = set(item) - {"quantity", "unit", "name"}
if extra:
fail(f"{field}: unknown keys {sorted(extra)}")
name = item.get("name", "")
if not isinstance(name, str):
fail(f"{field}.name must be a string")
unit = item.get("unit")
if unit is not None and (not isinstance(unit, str) or (unit not in UNITS and unit.lower() not in UNITS)):
fail(f"{field}.unit {unit!r} is not a known unit")
if unit is not None and unit not in UNITS:
unit = unit.lower()
q = item.get("quantity")
if q is None:
return {"text": name, "low": None, "high": None, "unit": unit, "name": name}
if isinstance(q, bool) or not isinstance(q, (int, float, str)):
fail(f"{field}.quantity must be a number or a string")
m = LINE.match(str(q))
if not m or m.group("rest").strip():
fail(f"{field}: cannot read the quantity {q!r}")
low = parse_number(m.group("q1"), field)
high = parse_number(m.group("q2"), field) if m.group("q2") else None
return {"text": f"{q} {unit or ''} {name}".replace(" ", " ").strip(), "low": low, "high": high, "unit": unit, "name": name}
if not isinstance(item, str):
fail(f"{field} must be a string or an object")
if len(item) > 500:
fail(f"{field} is longer than 500 characters")
m = LINE.match(item)
if not m:
return {"text": item, "low": None, "high": None, "unit": None, "name": item.strip()}
low = parse_number(m.group("q1"), field)
high = parse_number(m.group("q2"), field) if m.group("q2") else None
unit, name = split_unit(m.group("rest"))
return {"text": item, "low": low, "high": high, "unit": unit, "name": name}
def nearest(x, step):
n = x / step
whole = n.numerator // n.denominator
if n - whole >= Fraction(1, 2):
whole += 1
return whole * step
def kitchen_round(x, canon, is_egg):
"""(rounded value, note or None)."""
if x == 0:
return x, None
if is_egg:
r = max(Fraction(1), nearest(x, Fraction(1)))
whole = x.numerator // x.denominator
return r, (None if r == x else f"{mixed(x)} eggs is not a whole number: rounded to {r} "
f"(or use {whole} and {mixed(x - whole)} of a beaten egg)")
steps = {"cup": [Fraction(1, 4), Fraction(1, 3)], "tbsp": [Fraction(1, 2)], "tsp": [Fraction(1, 8)], "fl oz": [Fraction(1, 2)],
"g": [Fraction(1)], "ml": [Fraction(1)], "oz": [Fraction(1, 4)], "lb": [Fraction(1, 4)], "kg": [Fraction(1, 100)],
"l": [Fraction(1, 100)], "pint": [Fraction(1, 4)], "quart": [Fraction(1, 4)], "mg": [Fraction(1)], "cl": [Fraction(1)],
"dl": [Fraction(1, 10)]}.get(canon, [Fraction(1, 2)])
if canon == "g" and x < 10:
steps = [Fraction(1, 10)]
best = min((nearest(x, s) for s in steps), key=lambda r: (abs(r - x), r.denominator))
note = None
if best == 0:
best = min(steps)
note = f"below the smallest step: shown as {mixed(best)}"
return best, note
def to_spoons(x, canon):
"""Re-express US volumes in a handier unit: under 1/4 cup (or a cup fraction off the 1/4 and 1/3 marks
that is a whole or half tablespoon) in tbsp, 3 tsp or more in tbsp, 4 tbsp or more in whole quarter cups."""
tsp = x * {"cup": 48, "tbsp": 3, "tsp": 1}[canon]
if canon == "cup" and x < Fraction(1, 4):
return tsp / 3, "tbsp"
if canon == "cup" and x < 1 and (x * 4).denominator != 1 and (x * 3).denominator != 1 and (x * 16).denominator <= 2:
return tsp / 3, "tbsp" # 3/8 cup is 6 tbsp: exact in spoons, not on the cup marks
if canon == "tsp" and tsp >= 3 and (tsp / 3).denominator in (1, 2):
return tsp / 3, "tbsp"
if canon == "tbsp" and tsp >= 12 and (tsp / 48 * 4).denominator == 1:
return tsp / 48, "cup"
return x, canon
def parse_factor(request):
given = [k for k in ("servings", "factor", "target") if k in request]
if len(given) != 1:
fail("give exactly one of servings, factor, target")
kind = given[0]
if kind == "servings":
s = request["servings"]
if not isinstance(s, dict) or set(s) != {"from", "to"}:
fail("servings must be {\"from\": n, \"to\": m}")
if any(isinstance(s[k], bool) or not isinstance(s[k], (int, float, str)) for k in ("from", "to")):
fail("servings must be numbers")
a, b = parse_number(str(s["from"]), "servings.from"), parse_number(str(s["to"]), "servings.to")
if a <= 0 or b <= 0:
fail("servings must be positive")
return b / a, f"servings {mixed(a)} -> {mixed(b)}"
if kind == "factor":
f = request["factor"]
if isinstance(f, bool) or not isinstance(f, (int, float, str)):
fail("factor must be a number or a string such as \"3/2\" or \"150%\"")
text = str(f).strip()
if text.endswith("%"):
value = parse_number(text[:-1], "factor") / 100
else:
value = parse_number(text, "factor")
if value <= 0:
fail("factor must be positive")
return value, f"factor {mixed(value)}"
return None, "target"
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
extra = set(request) - {"ingredients", "servings", "factor", "target", "round", "convert_units"}
if extra:
fail(f"unknown fields: {sorted(extra)}")
items = request.get("ingredients")
if not isinstance(items, list) or not items or len(items) > 500:
fail("ingredients must be a list of 1 to 500 lines")
do_round = request.get("round", True)
convert = request.get("convert_units", True)
if not isinstance(do_round, bool) or not isinstance(convert, bool):
fail("round and convert_units must be booleans")
lines = [parse_line(item, i) for i, item in enumerate(items)]
factor, how = parse_factor(request)
if factor is None:
target = request["target"]
if not isinstance(target, dict) or not {"ingredient", "quantity"} <= set(target) or set(target) - {"ingredient", "quantity", "unit"}:
fail("target must be {\"ingredient\": name, \"quantity\": q, \"unit\"?: u}")
wanted = str(target["ingredient"]).strip().lower()
matches = [ln for ln in lines if ln["low"] is not None and wanted and wanted in ln["name"].lower()]
if not matches:
fail(f"target: no ingredient with a quantity matches {target['ingredient']!r}")
line = matches[0]
if line["high"] is not None:
fail("target: the matching ingredient has a range; scale by servings or factor instead")
tq = parse_number(str(target["quantity"]), "target.quantity")
if tq <= 0 or line["low"] == 0:
fail("target: quantities must be positive")
tunit = target.get("unit", line["unit"])
if tunit is not None and tunit not in UNITS and str(tunit).lower() not in UNITS:
fail(f"target.unit {tunit!r} is not a known unit")
if tunit is not None and tunit not in UNITS:
tunit = tunit.lower()
src, dst = UNITS.get(line["unit"]) if line["unit"] else None, UNITS.get(tunit) if tunit else None
if (src is None) != (dst is None):
fail("target: give the target in the recipe's unit")
if src is None or src[0] == dst[0]:
factor = tq / line["low"]
else:
fam = {"volume": "v", "metric_volume": "v", "mass": "m"}
if src[2] is None or dst[2] is None or fam.get(src[1]) != fam.get(dst[1]) or fam.get(src[1]) is None:
fail(f"target: cannot convert {dst[0]} to the recipe's {src[0]}")
def base(q, u):
b = q * u[2]
return b * US_ML_PER_TSP if u[1] == "volume" else b
factor = base(tq, dst) / base(line["low"], src)
how = f"target {mixed(tq)} {tunit or ''} of {line['name']}".replace(" ", " ")
out, notes_all = [], []
for ln in lines:
entry = {"original": ln["text"], "name": ln["name"]}
if ln["low"] is None:
entry["scaled"] = ln["text"]
entry["note"] = "no quantity: adjust to taste"
out.append(entry)
continue
canon, family = (UNITS[ln["unit"]][0], UNITS[ln["unit"]][1]) if ln["unit"] else (None, "count")
is_egg = canon is None and re.search(r"\beggs?\b", ln["name"].lower()) is not None
values = [ln["low"] * factor] + ([ln["high"] * factor] if ln["high"] is not None else [])
entry["exact"] = " - ".join(mixed(v) for v in values) + (f" {canon}" if canon else "")
unit_out = canon
shown = values
notes = []
if convert and canon in ("cup", "tbsp", "tsp"):
converted = [to_spoons(v, canon) for v in values]
if len({u for _, u in converted}) == 1:
shown, unit_out = [v for v, _ in converted], converted[0][1]
if do_round:
rounded = []
for v in shown:
r, note = kitchen_round(v, unit_out, is_egg)
rounded.append(r)
if note:
notes.append(note)
if rounded != shown and not is_egg:
notes.append("rounded for measuring")
shown = rounded
entry["quantity"] = " - ".join(mixed(v) for v in shown)
entry["unit"] = unit_out
label = unit_out
if unit_out in PLURAL and max(shown) > 1:
label = PLURAL[unit_out]
entry["scaled"] = f"{entry['quantity']}{' ' + label if label else ''} {ln['name']}".strip()
if is_egg and values[0].denominator != 1:
entry["flag"] = "eggs"
if family == "other":
notes.append("pinches and dashes do not scale precisely")
if notes:
entry["note"] = "; ".join(notes)
out.append(entry)
result = {"factor": mixed(factor), "factor_decimal": format(float(factor), ".6g"), "basis": how, "ingredients": out,
"rounding": "cups to 1/4 or 1/3, tbsp to 1/2, tsp to 1/8, g and ml to 1 (0.1 g under 10 g), oz and lb to 1/4, eggs whole" if do_round else "none"}
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/recipe_scale/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/recipe_scale/1.0.0/recipe_scale.py