Munchable is a gut-health app. You tell it which conditions you have, you scan a barcode, and a deterministic rules engine tells you whether the product suits you. Most of what it reads is somebody else's ingredients list, transcribed off a packet, which means the engine spends a lot of its time being careful about what it does not know.
Recipes are the exception, and the exception is the interesting part.
A recipe is the only food we can be certain about
When the app reads a label, the list can be truncated, the sub-ingredients of a bought sauce can be missing, and a compound ingredient can hide the thing that actually matters. The engine handles that by failing closed: incomplete data produces a caution or a "can't assess", never a confident green.
A recipe has no such gap. We wrote the ingredient list. There is nothing missing for the engine to be careful about. That is the entire reason the recipe library exists as its own package, and it is why a recipe page is allowed to say "this suits you" in a way a scanned menu never can.
That privilege is paid for with discipline, and the discipline is one rule.
The scored product is derived, never authored
A recipe page has to show a cook something like this:
2 chicken breasts, sliced
150 g white rice
1 carrot, cut into thin batons
1 tsp rapeseed oil
15 g fresh ginger, grated
The rules engine wants something else entirely: ingredient nodes with percentage estimates, tags in the order a label would print them, fat and fibre per 100 g, a serving size in grams.
The obvious implementation is to author both. Write the pretty list for the page, write the engine's view alongside it, keep them in step. It works right up until somebody changes the rice from 150 g to 200 g and updates one of the two.
So the recipe type has no engine fields at all. It has quantities a cook reads, each line carrying its own weight in grams and the canonical tags it contributes, and everything the verdict is computed from is a function of that one array:
export function toProduct(recipe: Recipe): Product {
const total = totalGrams(recipe);
const byWeight = [...recipe.ingredients].sort((a, b) => b.grams - a.grams);
const { fat, fiber } = batchNutrition(recipe);
return {
barcode: `recipe:${recipe.slug}`,
ingredients: byWeight.map((i) => ({
id: identityTag(i),
text: i.name,
percentEstimate: (i.grams / total) * 100,
})),
nutriments: {
fat100g: (fat / total) * 100,
fiber100g: (fiber / total) * 100,
},
serving: { sizeG: total / recipe.serves },
statesTags: ['en:ingredients-completed'],
unknownIngredientsN: 0,
dataSource: 'recipe',
};
}
There is no recipe-specific scoring path behind that. toProduct hands the engine exactly the same shape a scan produces, and the same rules run.
Three details in there are load-bearing:
Weight order, not cooking order. Labels are written heaviest first and the engine reads position as a signal, so the derived copy is sorted by grams. The recipe keeps the cook's order for the page, which is a different order, and neither one has to know about the other.
Real percentages. A scanned label almost never tells you how much of anything is in the product, so the engine has to estimate from position. A recipe knows what it weighs. Every node carries a true percentage, which means the rules that care whether an ingredient is a main component or a trace decide it on fact instead of on a guess.
A completeness flag that is honest. The engine has a state tag meaning "this ingredients list is complete". On a captured label that is a claim about somebody else's transcription. Here we wrote the list, so it is simply true.
The numbers nobody types
Two of the conditions Munchable supports need fat, or fat and fibre, before their rules can say anything at all. Without those numbers they return "unknown", which on a recipe screen means every reader with that condition opens a library of shrugs.
The first plan was for recipe authors to type a per-serving fat figure onto each recipe. That is a number no reviewer can check, and one that silently becomes wrong the moment somebody edits a quantity.
Instead there is a small table of fat and fibre per 100 g keyed by ingredient tag, and the recipe's figures are summed from it. The lookup throws rather than defaulting 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 an unknown ingredient to zero fat would hand a low-fat reader a green verdict built on a hole in the data. Throwing means CI finds it, months before a device does.
The hole we cannot derive
Derivation gets you a long way and then stops, because a recipe line says "stock" and the reader owns a particular jar of stock. Bought stock, bread and sauces routinely carry onion, garlic or wheat that the recipe never mentions.
So ingredient lines can be marked packaged, which puts a note on screen: brands differ, scan the one in your cupboard. It is deliberately not applied to anything that merely comes in a bag. Rolled oats are oats. Spending a reader's attention on a warning with nothing behind it is how you train them to ignore the warnings that matter.
The page carries the other caveat too: the check is against the recipe as written, and swapping an ingredient changes it.
The gate
A recipe that the engine cannot score is not curated data, it is a draft. So the test suite runs every recipe through the real engine, once per condition, at the strictest setting the app offers, and refuses anything that comes back unsure:
forEachRecipe('scores for all seven conditions, with no unknowns', (r) => {
const product = toProduct(r);
for (const condition of CONDITIONS) {
const fit = fitCheck(product, soloProfile(condition));
assert.notEqual(fit.confidence, 'unknown');
assert.notEqual(fit.perCondition[0].verdict, 'unknown');
}
});
Around it sit the rules that catch the mistakes a careful author still makes. Every tag has to be canonical and known to the taxonomy. Cooking water is not allowed on an ingredient line, because adding half a litre to the batch weight shrinks every other ingredient's percentage and can quietly demote a real trigger. And there is a density band, which is my favourite test in the repo:
const ML = { tbsp: 15, tsp: 5, ml: 1 };
const density = i.grams / (i.qty * ML[i.unit]);
assert.ok(density >= 0.15 && density <= 1.6);
Grams are the truth and the written quantity is decoration, so nothing catches an author typing "1 tbsp" beside 200 g except arithmetic. Real cooking densities run from about 0.27 for chopped chives to about 0.9 for oil, so the band is wide. It is not trying to be clever. It is trying to catch a misplaced decimal point, and a spoonful recorded at 200 g lands at 13 g/ml and fails instantly.
There are coverage floors as well: a minimum number of recipes that suit each condition on its own, and a smaller minimum for every pair of conditions. The app shows a reader only the recipes that fit them, so a thinly covered condition is not a list of warnings, it is an empty screen.
The serving stepper is safe by arithmetic
Changing the serving count multiplies every quantity by the same factor. Percentages are ratios, per-100 g figures are ratios, and the serving size is total weight over servings. All three are invariant under that multiplication, so a reader cooking for four sees the same verdict as a reader cooking for two, and a test pins it. No branch in the UI had to be written to make that true.
Go and look at one
The live pages are the point of all this, so open one:
-
Ginger chicken rice bowl. Under the method there is a section headed "How this recipe was checked", and above it a line reading "About 342 g a serving, with 8 g of fat and 4 g of fibre in it". Nobody typed 342, or 8, or 4. They are
total / servesand two sums over the grams column on that same page. - The whole library, where every card's condition list came out of the same test run described above.
- The conditions, if you want to see what each rule set is actually looking for.
The general shape is not specific to food. If your product renders one representation of something to a human and feeds a second representation of it to an algorithm, and a person maintains both, the two will disagree eventually and the disagreement will show up as the algorithm being confidently wrong. Derive one from the other, then write the test that proves the derived one is complete enough to act on.
Top comments (0)