Munchable reads packaged food labels and tells you whether the product fits the gut conditions you manage. It also ships a recipe library, 38 of them, published at munchable.app/recipes and filtered for you inside the app.

The recipes are not a separate feature with a separate code path. A recipe is converted into the same shape as a product and run through the same engine, which is what lets a page say who it suits: munchable.app/recipes/gentle-rice-congee says it is cleared for all eight conditions, munchable.app/recipes/baked-salmon-with-dill-potatoes names five of them. An earlier post covered that decision.

This post is about the consequence nobody asks for when they add a serving stepper to a recipe screen.

The hazard

In most recipe apps, the serving control is cosmetic: it multiplies the numbers you read and nothing else depends on it. Here, the quantities feed a verdict. So "cooking for six" is a user action that could, in principle, change whether the app says a dish suits you.

There is no acceptable version of that. A dish is the same dish at any batch size, and a control that changed the answer would mean the answer was never about the dish.

Where the numbers come from

Every figure on a recipe page is derived from one array: the ingredient lines, each with a weight in grams. The live congee page prints:

50 minutes, serves 2. About 178 g a serving, with 8 g of fat and 1 g of fibre in it. 

None of those three numbers is stored. The serving size is the batch weight divided by the servings. The fat and the fibre are summed from a per-ingredient table and divided the same way. Cooking water is not an ingredient line at all, it belongs to the steps, so it cannot dilute anything.

That is the "no second copy" rule applied to arithmetic, and it is the same reason the per-condition line on the page is computed rather than authored. A hand-typed per-serving figure is a number nobody can check, and one that an author silently invalidates the moment they change a quantity.

The invariant

Scaling is deliberately boring:

const factor = serves / recipe.serves; return { ...recipe, serves, ingredients: recipe.ingredients.map((i) => ({ ...i, qty: i.qty * factor, grams: i.grams * factor, })), }; 

Every weight is multiplied by the same factor, so each ingredient's share of the batch is unchanged, the per-100 g figures are unchanged, and the per-serving weight is unchanged because both the numerator and the denominator moved. Nothing the engine reads is an absolute batch weight.

Written as a claim: for any recipe R and any serving count s, the derived product of scaleTo(R, s) has the same percentages, the same per-100 g values and the same serving size as the derived product of R.

Checking the claim rather than believing it

The package has a unit test for this on one fixture:

assert.equal(toProduct(doubled).serving?.sizeG, toProduct(recipe).serving?.sizeG); 

One fixture is enough to catch a refactor that breaks the formula. It is not enough to tell you the property holds across a real library, where ingredients have units and some of them are counted rather than weighed. So I ran it across everything:

let drift = 0; for (const r of RECIPES) { const base = toProduct(r); for (let s = 1; s <= 8; s++) { const p = toProduct(scaleTo(r, s)); if (Math.abs((p.serving?.sizeG ?? 0) - (base.serving?.sizeG ?? 0)) > 1e-9) drift++; if (Math.abs((p.nutriments?.fat100g ?? 0) - (base.nutriments?.fat100g ?? 0)) > 1e-9) drift++; } } 

38 recipes, serving counts 1 through 8, two derived numbers each:

recipes 38 ingredient lines 229 distinct ingredients 62 scaled products that differ from the base: 0 of 608 serving weight across the library: 55 g to 342 g fat per serving: 0.3 g to 22.7 g 

Zero of 608. Exact equality, not a tolerance, because the arithmetic is the same multiplication applied to numerator and denominator alike.

The one place the two lists genuinely differ

Here is the part I would want to read in somebody else's post, so I will write it about my own.

The quantities a cook reads are rounded. They have to be, because "0.75 tsp" is a number and "¾ tsp" is an instruction:

assert.equal(formatQty(0.5, 'tsp'), '½'); assert.equal(formatQty(1.5, 'tbsp'), '1½'); assert.equal(formatQty(0.26, 'tsp'), '¼'); assert.equal(formatQty(112.4, 'g'), '110'); // Never round a real ingredient away to nothing. assert.equal(formatQty(0.1, 'g'), '1'); 

Weights of 20 g and up round to the nearest 5 g, spoons round to familiar fractions, and nothing is ever allowed to round to zero. So on a scaled recipe, the printed list is not arithmetically identical to the list that was scored: the engine reads the exact grams, and the cook reads 110 g where the data says 112.4 g.

I am happy with that, and it is worth stating out loud rather than discovering. The rounding is in the direction of usability on a number a human will approximate anyway, the verdict is computed from the unrounded weights, and the one rounding rule that would matter (an ingredient disappearing from the list entirely) is the one the code refuses to do.

The table that throws

Fat and fibre come from a per-ingredient table keyed on the ingredient's identity. A missing entry does not default to zero:

const entry = NUTRITION[tag]; if (!entry) { throw new Error( `No nutrition entry for ${tag} ("${ingredient.name}" in ${recipe.slug}). Add it to nutrition.ts.`, ); } 

Defaulting to zero would be the worst available failure: a fat-free answer built on a gap, handed to the person for whom fat is the whole question. Throwing means a recipe with an unknown ingredient cannot be built at all, which CI notices long before a phone does.

The audit also turned up the harmless direction of the same drift. The table holds 68 entries and the library uses 62 of them:

unused: red bell pepper, cumin, paprika, bay leaf, oregano, vanilla extract 

Six ingredients that a recipe used to contain, or that I added in anticipation of one. They cost nothing and they prove the asymmetry is the right way round: an entry with no recipe is dead weight, a recipe with no entry is a build failure.

If you want to see the output of all this, every recipe page prints its own derived figures, and the per-condition line under "Who this suits" is read at the strictest setting the app offers. Start at munchable.app/recipes and open any of the 38.