Munchable is a barcode scanner for digestive conditions. Point it at a pack, and rule sets running on your own device say whether it fits the conditions you picked.
The app also has a recipe library, for the moment after an "avoid" verdict when the question stops being "can I eat this" and becomes "then what do I eat". And a recipe library creates a claim problem that a barcode never had. Every recipe card says who it suits. That sentence appears on a public page, is read by somebody who has not told us anything about themselves, and gets acted on in a kitchen.
So the rule we settled on is that a recipe page may not claim anything the app would not say on a scan. The implementation is less clever than it sounds, and the fact that it is dull is the point.
A recipe becomes a product
The engine's input shape is whatever a scan produces: ingredients in descending order by proportion, each with a taxonomy tag and an estimated percentage, plus a handful of per-serving nutrition figures. A curated recipe is an array of ingredients with gram weights and tags on them. The bridge is one function:
export function toProduct(recipe: Recipe): Product {
const total = totalGrams(recipe);
if (total <= 0) throw new Error(`Recipe ${recipe.slug} weighs nothing.`);
const byWeight = [...recipe.ingredients].sort((a, b) => b.grams - a.grams);
const { fat, fiber } = batchNutrition(recipe);
const ingredients: IngredientNode[] = byWeight.map((i) => {
const percent = (i.grams / total) * 100;
// ...
});
}
Grams in, percentages out, sorted heaviest first, which is exactly how an ingredients list on a pack is ordered and therefore exactly what the engine already understands. After that, a recipe is indistinguishable from a product to every rule set we have. No recipe-specific scoring path exists, because there is nothing for it to do.
The header comment on that file is the claim I care about:
toProductis the whole reason curated recipes are trustworthy: the tags, percentages, fat, fibre and serving size the verdict is computed from are all derived from the sameingredientsarray rendered on screen. There is no second copy of the ingredient list to fall out of date, and no nutrition number an author can mistype.
The ingredient lines you read, the nutrition figures under the title and the suitability sentence are three renderings of one array. Editing a quantity moves all three or none.
Suitability, at the strictest setting the app offers
With that bridge in place, "does this recipe suit a condition" is the scan check with one condition switched on:
function suits(recipe: Recipe, condition: ConditionId): boolean {
const fit = fitCheck(toProduct(recipe), {
conditions: [condition],
settings: { lactose: { sensitivity: 'high' } },
});
return fit.verdict === 'good';
}
Two deliberate choices.
The threshold is good, not "not avoid". A recipe that merely fails to be bad does not get listed as suiting anything.
And the settings are pinned to the strictest configuration a user could choose, rather than the defaults. A visitor reading a public page is anonymous, so there is no correct setting to use, and the asymmetry of being wrong is total: a page that is stricter than the reader means they skip a meal they could have eaten, while a page that is looser than the reader means they cook something that hurts. The code comment puts it as "the public claim can never be softer than what the app would do for the person who acts on it".
Each page then names the conditions it passed for, and because the whole set is evaluated per recipe, the per-condition index pages come free: a condition guide lists the recipes whose evaluation included it, rather than from a hand-maintained tag.
The same library, two very different presentations
Here is the part that took the longest to see, and it is a product decision rather than a technical one.
In the app, a reader has a profile. So the library is filtered to what fits them and shows no badge, no score and no verdict. A verdict on a filtered list would be the app arguing with a decision it has already made on the reader's behalf. If a recipe is on screen, it fits; that is what being on screen means.
On the web, the visitor has no profile, so the same evaluation is surfaced as an index label instead: "suits a low FODMAP pattern" is a fact about the recipe, not a judgement about the reader. It is also, usefully, the thing they typed into a search engine.
One evaluation, two presentations, and the difference is entirely about what the renderer knows about the person reading.
Writing the sentence is harder than computing it
We have seven conditions and most of the library clears all of them, which makes the naive sentence terrible. Enumerating seven condition names produces five lines of card that say less than three words would, so the line says the shortest true thing:
const suitsLine =
conditions.length === 0
? ''
: missing.length === 0
? 'all seven conditions'
: missing.length <= 2
? `every condition except ${joinNames(missing.map((id) => CONDITION_LABELS[id]))}`
: joinNames(conditions.map((id) => CONDITION_LABELS[id]));
Near-misses invert, because "every condition except gastroparesis" is both shorter than naming the other six and more useful: the exception is the part a reader is scanning for.
When it does enumerate, it uses the engine's short labels rather than the page headings, and that was a bug fix. Two of our headings contain commas, and joining those into a list produced "GERD and reflux, Lactose intolerance and IBD, Crohn's and colitis", which reads as the wrong number of conditions. The short labels are also the words the app itself uses, so both surfaces name a condition identically.
The meta description is generated from the same values for the same reason:
metaDescription: guides.length > 0
? `${recipe.summary} ${recipe.minutes} minutes, serves ${recipe.serves}. Checked against ${suitsLine}.`
: `${recipe.summary} ${recipe.minutes} minutes, serves ${recipe.serves}.`
Dozens of hand-written meta descriptions drift out of agreement with their pages the first time a quantity changes. A generated one cannot.
Look at it yourself
- munchable.app/recipes is the library, with the suitability line on every card.
-
Chicken and rice soup is one page end to end. The line "Munchable's rules clear this recipe for all seven conditions, read at the strictest setting the app offers" is the output of the
suitsfunction above, not a sentence anybody typed, and the per-serving grams under it come from the same ingredient array as the list further down. - munchable.app/conditions/low-fodmap-ibs is the condition side of the same evaluation: the recipes listed there are the ones whose check included that rule set.
- The ingredient-level equivalent is munchable.app/answers, where every page is an engine answer rather than written prose. I wrote about how those pages are built in Our 373 answer pages contain no written answers, because each one runs the rules engine twice at build time, and about why recipes carry grams and nothing else in Our recipe pages have no nutrition fields, because the grams are the only thing anybody typed.
If you ever find yourself writing a second scoring path for content you author yourself, that is worth stopping on. Ours became a single adapter into the shape the engine already took, and the result is that there is no version of the product where a page can claim something the app would refuse to.
Top comments (0)