A rules engine that scores food labels has a failure mode no error tracker catches: it reads an ingredient, does not recognise it, and says “not assessed”. For the long tail of ingredients that is honest. For the ingredients a condition is about, it is a user the app exists for being told nothing.
Munchable had three of those in one week. A lactose-intolerant user scanned kefir. An IBS user scanned a pack that said “soya”. A reflux user scanned tea. All three came back not assessed. Each was a one-line data fix, and none of them would have happened if the engine had a written-down promise of what it covers.
So now it does, and the promise is a test.
Words, not ids
The promise list is a hand-maintained file of label words, grouped by the rule map that should catch them:
export const CONDITION_COVERAGE = {
fodmap: ['onion', 'garlic', 'wheat', 'soya', 'honey', 'apple', ...], // 78
lactose: ['milk', 'kefir', 'buttermilk', 'custard', 'casein', ...], // 61
gerd: ['tea', 'coffee', 'tomato', 'onion', 'peppermint', ...], // 55
ibd: ['carrageenan', 'e171', 'chorizo', 'hydrogenated-vegetable-oil', ...], // 41
gastroparesis: ['pecan-nut', 'macadamia-nut', 'date', 'celery', ...], // 37
} as const;
Enter fullscreen mode Exit fullscreen mode
The header explains the choice of words over taxonomy ids: “They go through canonicalizeTag exactly as a label does, so a promise here also covers the alias path that gets a label word onto the id a rule keys on.” Half the gaps the first audit found were not missing rules. They were missing aliases, where the rule existed under one spelling and the label used another.
There are three lists in total, and each has its own definition of covered:
List Entries Covered means Condition words 272 the word canonicalises to a known id, and that id or an ancestor is a key in the condition’s map Everyday words 15isReviewed(id) is true, so the result screen would never list it as not assessed
Allergen words
163
a one-ingredient product built from it raises a contains hit for that allergen
The fifteen everyday words are the small list with the strongest reason. Water, salt, sugar, vinegar, sunflower oil, black pepper. “A verdict that listed ‘water, salt, sugar’ as not assessed would be crying wolf, and would bury the one name that mattered.”
The test
function reaches(map: keyof typeof MAPS, id: string): boolean {
const table = MAPS[map]();
return [id, ...ancestorsOf(id)].some((t) => Object.hasOwn(table, t));
}
for (const [map, words] of Object.entries(CONDITION_COVERAGE)) {
test(`${map}: every promised label word reaches the ${map} map`, () => {
const failures: string[] = [];
for (const word of words) {
const id = canonicalizeTag(`en:${word}`);
if (!isKnownTag(id)) failures.push(`${word} -> ${id} is not a taxonomy id`);
else if (!reaches(map, id)) failures.push(`${word} -> ${id} reaches no ${map} rule`);
}
assert.deepEqual(failures, []);
});
}
Enter fullscreen mode Exit fullscreen mode
Two small things make this pleasant to live with. The word is walked exactly the way a label word is walked, through canonicalisation and then up the taxonomy’s ancestors, so the test exercises the same path the scan does. And failures are collected into an array and compared against an empty one, so a single run lists every gap in a map rather than stopping at the first.
The allergen half builds a real product and runs the real check:
const product = normalizeProduct({ barcode: '0000000000000', ingredientsTags: [id] });
const hit = checkAllergens(product, [allergen]).hits.find((h) => h.allergen === allergen);
if (!hit || hit.level !== 'contains') failures.push(`${word} -> ${id} does not raise ${allergen}`);
Enter fullscreen mode Exit fullscreen mode
What the first run found
Adding the list before fixing anything is the point, because the first run is the audit. It found:
- “soya” landed on
en:soya, which the allergen layer keyed but the FODMAP map did not (it only haden:soya-bean), so the bare word came back not assessed. - kefir, buttermilk, custard, casein and caseinate had no lactose tier.
- tea, mate, ketchup, harissa, onion and garlic had no reflux entry.
- E171, E435, E952 and the nitrates had no IBD marker.
- pecan, macadamia, brazil nut, dates, prunes, celery, cabbage and broccoli had no gastroparesis texture.
- some fifty everyday spellings (soy, oats, prawn, groundnut, sulphite, hot dog, sparkling water) were not taxonomy ids at all.
Closing those took about two hundred lines across the rule maps and alias tables. Every one of them is now visible as a public page: Is soya low FODMAP? and Is kefir high in lactose? both exist because the test demanded they could be answered.
The workflow it created
The docs now prescribe an order of operations for a report of the form “the app should obviously have had something to say about this”:
add the word here first, watch the test fail, and then add the alias, extension or map row that makes it pass.
There is also a human-readable twin, a script that prints ok or GAP per word with the canonical id, the map that reaches it and whether the screen would call it not assessed. Same lists, same walk, exit code 1 on any gap. The test is for the build; the script is for the person deciding what to fix.
What it deliberately does not do
The list is the common cases, not the long tail. Munchable has a nightly curation job that widens coverage from real labels, and a promise list that tried to enumerate everything would just be a slower copy of that job. The header states the boundary: these are “the ones that must never depend on a job having run.”
I wrote earlier about the bug that made the app say safe, where a word the engine could name but not score raised confidence without contributing a reason. This test is the other half of that fix: for the words that matter most, being nameable and being scoreable are checked together, before anything ships. The full index of what is covered is at munchable.app/answers.