Six of the seven conditions Munchable scores are about named ingredients. Bile acid malabsorption is the odd one out. Diet helps it mainly through fat, so the rule set keys on a number: fat per serving, read from the nutrition panel.
Plenty of packs carry no nutrition panel. For those, the axis had nothing to say and returned unknown. The comment that opens the fix describes why that was the worst possible answer:
a reader holding a packet of shortbread does not need to be told that Munchable cannot tell. Most labels without a fat figure still list their ingredients, and an ingredients list that opens with butter is not ambiguous about carrying fat.
This post is about the map that lets the engine read that, the precedence rules around it, and the two bugs it caused on the way in.
A map of the ingredient, not the product
FAT_DENSITY_TAGS holds sixty-seven ingredients with a typical fat content in grams per 100 g of the ingredient itself. Pressed oils at 100. Butter at 81, suet at 94, lard at 100. Double cream at 48 down to sour cream at 20. Nut butters in the fifties, nuts from macadamia at 76 down to pistachio at 45, cheeses from 33 for the generic parent down to mozzarella at 22.
The header is explicit that these describe the substance and not the food: "butter is 81 g/100 g, a biscuit made with butter is not. Turning one into the other is the rule set's job, from where the ingredient sits on the label, not this file's."
Two thresholds do that turning, and they are deliberately separate numbers from the per-serving ones:
export const BAM_FAT_CAUTION_G = 10; // grams per serving of FOOD
export const BAM_FAT_AVOID_G = 17;
export const BAM_INGREDIENT_FAT_DENSE_G = 50; // grams per 100 g of INGREDIENT
export const BAM_INGREDIENT_FAT_MODERATE_G = 20;
A dense ingredient in a main position is an avoid. A dense ingredient lower down, or a moderate one anywhere, is a caution. Nothing on the list above 20 g means the axis stays unknown, exactly as before, because "no fat ingredient named" is not evidence of a low-fat product.
Precedence one: the measured number always wins
evaluate(product) {
const fat = fatPerServing(product);
if (!fat) {
// No nutriment panel. Read the ingredients list instead of giving up.
const dense = fattestIngredient(product);
if (dense) {
return {
condition: 'bam',
verdict: dense.tier,
reasons: [{
code: `bam_fat_ingredient_${dense.tier}`,
message: dense.primary
? `${name} is a main ingredient and carries around ${dense.grams} g fat per 100 g, with no fat figure on the label`
: `Contains ${name}, around ${dense.grams} g fat per 100 g, with no fat figure on the label`,
detail: { fatPer100gOfIngredient: dense.grams, primary: dense.primary },
}],
};
}
}
The fallback only runs when the panel is absent. A test called "a measured fat figure always beats the ingredient inference" puts butter first on the list with a panel that says 4 g per 100 g and expects good. The number wins, because a measured figure describes the actual product and the map describes a typical one.
Precedence two: nearest tag, then severity, then density
Normalisation expands every ingredient to its taxonomy ancestors, so a label saying mozzarella arrives carrying en:cheese too. Cheese is 33, mozzarella is 22. The first version picked the denser of the two, which both overstated the fat and published a mozzarella page whose reason line talked about cheese.
const candidates = product.ingredientsTags.filter((t) => FAT_DENSITY_TAGS[t] !== undefined);
// Drop any candidate that a more specific candidate already speaks for.
const specific = candidates.filter(
(tag) => !candidates.some((other) => other !== tag && ancestorsOf(other).includes(tag)),
);
Then severity before density, because what is being estimated is the fat in the product: a product whose main ingredient is cream outranks one carrying palm oil in ninth place. A test asserts the mozzarella reason matches /mozzarella/ and does not match /cheese/. You can see the result on Is mozzarella OK with BAM?: the reason line names mozzarella and the answer is "it depends on how much", not "no".
Sixty-seven pages appeared from zero
Munchable's ingredient answer pages are generated by running the engine on a synthetic one-ingredient product, twice: once with the ingredient first on the list, once with it sixth. Before this commit, bile acid malabsorption generated no answer pages at all, because a one-ingredient probe has no nutrition panel and every probe came back unknown. The comment that used to explain this said so plainly: "no ingredient carries a BAM fact today and no BAM page is generated."
The fat map changed that at once. Is suet OK with BAM? reads: "Suet carries roughly 94 g of fat per 100 g", then the engine's own reason string, then a note that a main ingredient is an avoid and a minor one a caution. Butter, olive oil and double cream each have one. The condition guide gained a paragraph about what happens when a pack has no panel.
The two bugs it caused
The first was in the probe itself. The synthetic product pads the ingredient under test with inert filler so it can be placed sixth. That filler included sunflower oil. The moment the engine learned to read fat-dense ingredients, every bile acid probe read the oil instead of the ingredient under test.
/**
* NO FATS OR OILS HERE. This list held `en:sunflower-oil` until the BAM rule set
* learned to read fat-dense ingredients, at which point the filler started
* answering the question: every BAM probe read the oil rather than the
* ingredient under test.
*/
const INERT_FILLER = ['en:water', 'en:salt', 'en:rice-flour', 'en:rice', 'en:citric-acid'];
The second was a number in a comment. Three places in the web app said there were 173 answer pages. That count had just moved by sixty-seven. The fix was to delete the number: "it moves every time a rule map grows, and a number in a comment goes stale silently."
What did not change
The rule set still says, in its own metadata, that its thresholds are placeholders pending a dietitian's sign-off and that the evidence is very low certainty. The fallback carries an extra note: "Read from the ingredients list because the label carried no fat figure, so it is an inference from what the product is made of rather than a measured amount." Both strings reach the result screen and the public pages verbatim.
An earlier post covered the general shape of this problem, knowing a word is not understanding it. This is the same principle applied to a number rather than a name: an ingredient the engine can name but cannot weigh is not one it has assessed.