Recipe Scaler: Servings, Units and Shopping Lists
EasyAI-assisted coding · AI-Assisted Debugging
Recipe Scaler and Shopping Lists
You've joined the platform team at a meal-kit company. The recipe service scales a recipe to the number of servings a customer orders and, since last quarter, builds one combined shopping list for a week of recipes. Support has three open complaints about the scaled amounts, and the product team has two features queued behind them. All quantities are exact fractions.Fraction values inside the service; nothing may ever go through floating point.
Codebase
| File | What it does |
|---|---|
kitchen/api.py | run(operations) entry point and the Dispatcher that routes each operation |
kitchen/service.py | Kitchen: the operations, with argument validation |
kitchen/catalog.py | RecipeCatalog: validates and stores recipes |
kitchen/scaling.py | Scales a recipe; picks the unit an amount is shown in; renders lines |
kitchen/units.py | The unit table: dimensions, systems, conversion factors, unit ladders |
kitchen/quantity.py | Parses quantity text ("1 1/2") and prints Fractions back |
kitchen/names.py | Ingredient-name normalization (used by everything that touches a name) |
kitchen/models.py | Recipe / Ingredient dataclasses, require_servings |
kitchen/pantry.py | Pantry (staples) — the replace operation is a stub for Part 2 |
kitchen/products.py | ProductBook (densities, package sizes) — stub for Part 3 |
kitchen/shopping.py | ShoppingListBuilder — stub for Parts 2 and 3 |
kitchen/config.py, kitchen/errors.py | Constants and every error code |
tests/ | Existing unit and end-to-end tests (several currently fail) |
Two files hold conventions this README deliberately does not repeat. The conversion factors in units.py are the rounded label values (1 tsp = 5 ml, 1 oz = 28 g, ...), *not* textbook values; use the table, never constants of your own. The exact name rules are in names.py; this README only promises that every name the service stores, compares or prints has gone through normalize_name.
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh kitchen and returns one result per operation. Every operation is [name, *args]. Errors are returned as "error: <code>" strings, never raised. An unknown (or non-string) operation name returns "error: unknown operation"; a wrong number of arguments returns "error: invalid arguments".
Quantity text is "3", "3/4" or "1 1/2" (whole number, exactly one space, fraction; the fraction of a mixed number may be improper). Surrounding whitespace is ignored, digits are 0-9, the value must be greater than zero and no denominator may be zero. Anything else is invalid quantity (this includes decimals like "1.5", and non-strings).
Printing quantities: a reduced fraction, with the whole part first when the value is 1 or more: "3", "3/4", "1 1/2", "2 1/4".
Units: tsp, tbsp, cup (US volume), ml, l (metric volume), oz, lb (US weight), g, kg (metric weight), each (count). Anything else is unknown unit. The service never converts between US and metric except where Part 3 says so.
Output lines are "<quantity> <unit> <name>", e.g. "1 1/2 cup all purpose flour", "3 each egg". (each is printed.)
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
add_recipe | recipe_id, base_servings, ingredients | true | invalid arguments, recipe exists, invalid servings, empty recipe, then per ingredient in list order: invalid ingredient, invalid quantity, unknown unit |
scale | recipe_id, servings | list of lines | recipe not found, invalid servings |
readable | quantity_text, unit | "<quantity> <unit>" | invalid quantity, unknown unit |
ingredientsis a list of[name, quantity_text, unit]triples.recipe_idmust be a non-empty string andingredientsa list of 3-element lists, otherwiseinvalid arguments(before anything else).base_servingsandservingsmust be integers>= 1(true/falseare not integers). A name that normalizes to nothing isinvalid ingredient. For one ingredient the name is checked first, then the quantity, then the unit; the first ingredient with any problem decides the error. A failedadd_recipestores nothing.- Ingredient order and duplicate ingredient lines are preserved by
scale. readableshows how an amount would be printed after the readable-unit rule of Part 1 (noeachrounding, no name).
Part 1: Scaled amounts are wrong (KIT-412, KIT-418, KIT-425, KIT-431)
Goal: make scale and readable follow the rules below.
Complaints from support and the culinary team:
- "Scale 1/3 cup × 3 and the card shows a number with 17 digits." (KIT-412)
- "Asking for the same recipe twice gives different amounts the second time." (KIT-418)
- "The recipe says half an egg, I scaled it to 1 serving and got
0 each egg; and 2 1/2 eggs came out as 2." (KIT-425) - "3 tsp should say 1 tbsp, and
1 3/10 lis not something anyone writes." (KIT-431)
Rules
- Scaling: every quantity is multiplied by
servings / base_servings, exactly. The stored recipe is never changed by scaling. - Countable items: an
eachquantity is rounded up to a whole number after multiplying (ceil). Other units are never rounded. - Readable unit: an amount (quantity × the unit's factor from
units.py) is shown in a unit of the same dimension and system as the unit it was written in;eachstayseach. Look at the system's units from largest to smallest. The first unituthat satisfies both
- the amount expressed in
uis>= u.floor(inclusive; floors are inunits.py: 1 fortbsp,l,lb,kg, and 1/4 forcup), and - that amount, as a reduced fraction, has a denominator of 1, 2, 3 or 4,
is used. If none qualifies, the system's smallest unit (tsp, ml, oz, g) is used with whatever fraction results. Example: 5 tbsp is 5/16 cup (denominator 16), so it stays 5 tbsp; 1500 ml is 1 1/2 l; 1300 ml is 1 3/10 l, so it stays 1300 ml; 1/2 tsp stays 1/2 tsp.
- Lines for
scaleare"<quantity> <unit> <normalized name>", in the recipe's ingredient order. Pantry staples are not removed byscale.
Example
[["add_recipe", "pancakes", 4, [["All-Purpose Flour", "1 1/2", "cup"], ["Whole Milk", "1 1/4", "cup"],
["Large Eggs", "2", "each"], ["Unsalted Butter", "3", "tbsp"], ["Sugar", "2", "tbsp"],
["Kosher Salt", "1/2", "tsp"], ["Baking Powder", "2", "tsp"]]],
["scale", "pancakes", 6]]returns true and ["2 1/4 cup all purpose flour", "30 tbsp whole milk", "3 each egg", "4 1/2 tbsp unsalted butter", "3 tbsp sugar", "3/4 tsp salt", "1 tbsp baking powder"] (1 7/8 cups of milk has denominator 8, so the milk drops to tablespoons.)
Part 2: Combined shopping lists (KIT-440)
Goal: implement shopping_list and set_pantry.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
shopping_list | plan = list of [recipe_id, servings] | list of lines | invalid arguments, empty plan, then for each entry in order: recipe not found, invalid servings |
set_pantry | names (list of ingredient names) | number of distinct staples now set | invalid arguments (not a list), invalid ingredient |
Rules
- Each plan entry is scaled exactly like
scale(soeachitems are rounded up per entry; the same recipe may appear several times in a plan and each entry is scaled and rounded on its own). Entries are then merged by normalized name and (dimension, system): tsp, tbsp and cup of the same ingredient add up;mlandladd up; but cups and ml do not (US and metric are never mixed), and volume, weight andeachentries of one ingredient stay separate lines. The merged total is then put through the Part 1 readable-unit rule. - Pantry staples are skipped entirely. A staple is matched on the *normalized* name. The default staples are in
config.py(DEFAULT_PANTRY).set_pantryreplaces the whole list (an empty list means no staples); names are normalized and duplicates counted once; if any name is invalid nothing changes. Pantry changes affect latershopping_listcalls only. - Order of lines: by normalized name (plain string order), then volume before weight before
each, and within a dimension US before metric. shopping_listnever changes any stored recipe.
Example (after adding pancakes as in Part 1, and omelet with base 2 servings: [["Eggs","3","each"],["Whole Milk","1/4","cup"],["Unsalted Butter","1","tbsp"],["Salt","1/4","tsp"],["Cheddar Cheese","1/2","cup"],["Scallions","2","each"]])
[["shopping_list", [["pancakes", 4], ["omelet", 4]]]]returns ["1 1/2 cup all purpose flour", "2 tsp baking powder", "1 cup cheddar cheese", "8 each egg", "4 each green onion", "2 tbsp sugar", "5 tbsp unsalted butter", "1 3/4 cup whole milk"]
Part 3: Density, and buying whole packages (KIT-466)
Fulfilment wants one line per ingredient, in the unit the warehouse stocks, and they buy in whole packages. This changes the Part 2 merge rule for ingredients that have a density. Everything else in Parts 1 and 2 stays as it was.
| Operation | Arguments | Returns | Errors (in order) |
|---|---|---|---|
set_density | name, grams_per_cup (quantity text) | true | invalid ingredient, invalid quantity |
set_package | name, size (quantity text), unit | true | invalid ingredient, invalid quantity, unknown unit |
Both operations normalize the name, overwrite any earlier value for it, and only affect later shopping_list calls. scale and readable are not affected by them.
Rules
- Density.
grams_per_cupmeans grams in onecupof the unit table (240 ml). An ingredient with a density is always bought by weight: all of its volume entries *and* weight entries, in any system, are converted to grams and added into one line (even if it only has volume entries). Volume entries convert viaml × grams_per_cup / 240.eachentries are unaffected and stay their own line. Ingredients without a density follow Part 2 exactly. - Unit of a density line: if *every* entry that contributed was written in a US unit (
tsp,tbsp,cup,oz,lb) the line uses the US weight ladder (oz,lb); if any entry was metric, it uses the metric weight ladder (g,kg). The Part 1 readable-unit rule applies. - Packages. If the ingredient has a package size and it is *comparable* with the line, the line becomes
"<count> x <size> <unit> <name>", wherecountis the total divided by the package size, rounded up, and<size> <unit>is the package exactly as defined (size printed as a quantity, unit not changed). Comparable means: for a density (weight) line, a package in any weight unit or any volume unit (a volume package is converted to grams with the density); for a non-density volume or weight line, a package of the same dimension and system; for aneachline, a package ineach. A package that is not comparable is ignored and the line is printed as a normal quantity. - Pantry staples are still skipped first; line order is unchanged (a density line sorts as weight, after any volume lines and before any
eachline of the same name).
Example
[["add_recipe", "bread", 2, [["Flour", "3", "cup"], ["Salt", "1", "tsp"]]],
["add_recipe", "cookies", 2, [["All-Purpose Flour", "300", "g"], ["Garbanzo Beans", "800", "g"]]],
["set_density", "Flour", "120"],
["shopping_list", [["bread", 2], ["cookies", 2]]],
["set_package", "Flour", "1", "kg"],
["set_package", "Garbanzo Beans", "400", "g"],
["shopping_list", [["bread", 2], ["cookies", 2]]]]returns true, true, true, ["660 g all purpose flour", "800 g chickpea"], true, true, ["1 x 1 kg all purpose flour", "2 x 400 g chickpea"] (3 cups of flour is 360 g; with the metric 300 g the line is metric).
Notes
- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour after each Part.
- The unit table and the name rules are part of the specification. If this README and the code seem to disagree about a convention, the README wins for the Parts' rules and
units.py/names.pywin for conversion factors, floors and name handling.
Example 1
- Input:
- [["add_recipe","pancakes",4,[["All-Purpose Flour","1 1/2","cup"],["Whole Milk","1 1/4","cup"],["Large Eggs","2","each"],["Unsalted Butter","3","tbsp"],["Sugar","2","tbsp"],["Kosher Salt","1/2","tsp"],["Baking Powder","2","tsp"]]],["scale","pancakes",6]]
- Output:
- [true,["2 1/4 cup all purpose flour","30 tbsp whole milk","3 each egg","4 1/2 tbsp unsalted butter","3 tbsp sugar","3/4 tsp salt","1 tbsp baking powder"]]
- Explanation
- Scaling by 3/2. 15/8 cup of milk has denominator 8, so milk is shown as 30 tbsp; 3 tsp of baking powder is exactly 1 tbsp (floors are inclusive).
Example 2
- Input:
- [["add_recipe","bread",2,[["Flour","3","cup"],["Salt","1","tsp"]]],["add_recipe","cookies",2,[["All-Purpose Flour","300","g"],["Garbanzo Beans","800","g"]]],["set_density","Flour","120"],["shopping_list",[["bread",2],["cookies",2]]],["set_package","Flour","1","kg"],["set_package","Garbanzo Beans","400","g"],["shopping_list",[["bread",2],["cookies",2]]]]
- Output:
- [true,true,true,["660 g all purpose flour","800 g chickpea"],true,true,["1 x 1 kg all purpose flour","2 x 400 g chickpea"]]
- Explanation
- Flour has a density, so 3 cups (360 g) and 300 g merge into one metric line (one entry was metric). Salt is a pantry staple. With packages, 660 g needs one 1 kg bag and 800 g needs exactly two 400 g cans.
Guidelines
- Python 3 standard library only
- Quantities are exact fractions.Fraction values; no floating point anywhere in the service
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Conversion factors come from kitchen/units.py (rounded label values, not textbook values); ingredient names always go through kitchen/names.py