en:preservative has 2 descendants and en:nut has 111, so our descendant cap was guarding the wrong direction
Munchable decides whether a packaged food suits the gut conditions you manage, by looking up the ingredients on its label in a taxonomy and running a rules engine over them. The rules are keyed on taxonomy ids, and a rul
Munchable decides whether a packaged food suits the gut conditions you manage, by looking up the ingredients on its label in a taxonomy and running a rules engine over them. The rules are keyed on taxonomy ids, and a rule fires on everything beneath its id. Key one on a cheese and you have covered every cheese under it.
Some of those rule rows are proposed by a model. The contract is narrow: it never sees an id, never writes an id, and never writes a word a user reads. It sees an ingredient in plain English with its place in the taxonomy and answers with enum values only. Then deterministic guards decide whether the answer merges.
The guard I expected to carry the most weight was generality, because inheritance is the whole risk. A row on a broad concept is a rule on a quarter of the aisle.
/**
* An automated rule may not sit on a concept with more descendants than this.
*/
export const MAX_DESCENDANTS = 150;
The job adds leaves. A human adds roots.
The cap works, in the direction it was aimed
Counting descendants in our graph today:
en:tuber 40 passes
en:seed 105 passes
en:nut 111 passes
en:root-vegetable 161 rejected
en:cheese 221 rejected
en:dairy 482 rejected
en:vegetable 759 rejected
That is the behaviour I wanted. Broad rules are real and useful, and our hand-written maps have several on purpose, but choosing to put a caution on four hundred dairy products is a product decision rather than a nightly job's decision.
A note on those numbers: the comment in our source says 36, 94, 107, 153, 387 and 694 for the same six ids, because that is what they were when it was written. The graph grows every time curation names a new ingredient under an existing concept. The cap is therefore computed at decision time from the live graph, never stored, and the only thing the comment is good for is explaining the shape of the choice.
Then the model was asked about the word "emulsifier"
Here are the descendant counts for a different family of ids:
en:thickener 1
en:preservative 2
en:stabiliser 2
en:emulsifier 5
en:colour 7
en:sweetener 15
Every one of them sails through a cap of 150. Every one of them is also among the broadest things you could possibly write a rule on, because they are not substances. They are what a substance is FOR.
A pack that says "emulsifier" without naming which one gets the tag en:emulsifier and nothing beneath it, so the graph thinks the word is a leaf. In the catalogue it is the opposite of a leaf: it is on a huge share of processed food, and it says nothing about which substance is actually in the box.
We found this the honest way. Asked about en:emulsifier, a model reasoned that "the label explicitly identifies an emulsifier" and filed it under an inflammatory-bowel marker that a real clinical guideline names for specific emulsifiers. The reasoning is not even wrong in isolation. Merged, it would have put a caution on roughly a tenth of our catalogue on the strength of a word that identifies nothing, and every one of those cautions would have read exactly as confident as a caution about a named substance.
The cap could not see it. Five children.
So the class words are named, with the reason attached
There is no clever generalisation here. The fix is a set, in code, with the incident written above it:
/**
* Additive CLASS words: what a substance is FOR, not which substance it is.
*
* A rule on one of these fires on every pack that writes "emulsifier" without
* naming it, which is most of them, and it fires on the harmless members
* exactly as hard as on the ones a guideline actually names.
*
* They pass the descendant cap because they have few descendants in the graph,
* so the cap does not catch them. Named here instead.
*/
const CLASS_IDS: ReadonlySet<string> = new Set([
'en:emulsifier', 'en:stabiliser', 'en:thickener', 'en:preservative',
'en:antioxidant', 'en:colour', 'en:flavouring', 'en:flavour-enhancer',
'en:acidity-regulator', 'en:anti-caking-agent', 'en:raising-agent',
'en:humectant', 'en:glazing-agent', 'en:firming-agent', 'en:sweetener',
// ...
]);
A hand-maintained exception list is usually a smell. This one earns its place because it encodes something the graph genuinely does not know and cannot be made to know cheaply: the difference between a category word and a thing.
The bland ingredients get the same treatment from the other direction, and that list lives in the engine rather than in the job:
/**
* Nearly every product contains these; a rule on one flags the catalogue.
* The engine owns the list (`HAND_CLEARED`) because it also has to know they
* are reviewed, not merely unscored.
*/
const BLAND_IDS: ReadonlySet<string> = HAND_CLEARED;
Water, salt and sugar are not rejected because they are unimportant. They are rejected because a rule on one of them is a rule on everything, and the engine needs the same list for a second purpose: to know that nobody still has to go and look at water.
Lineage plausibility is the cheapest check we have
The other guard that catches this class of answer costs nothing per decision, because it only asks the graph a question. A claim has to be credible for where the ingredient sits: a lactose load is only plausible for something under dairy, a whole-nut texture only for a nut or a seed, an additive marker only for something whose id is shaped like an E-number.
Two things about it are worth saying out loud.
It is per-claim, not per-answer. An implausible claim is dropped on its own and the rest of the answer stands, because a model can easily be right about one property of an ingredient and wrong about another.
And one of our rule sets is exempt, deliberately. Reflux triggers have no common ancestor: a coffee is a beverage, a kimchi is a vegetable, a chocolate is a cocoa product. Any anchor we invented there would produce false rejections, so that map is guarded by generality and confidence alone. A guard that does not fit its data should be absent rather than approximate.
Why all the guards lean the same way
A rejected proposal leaves an ingredient exactly where it already was: undecided, which is the state it was in this morning and the state a few thousand of its neighbours are in right now.
An accepted bad proposal either puts a warning on innocent food, which teaches a user to ignore warnings, or silently covers a real trigger, which is the failure we exist to prevent. These are not the same size, so the guards are not symmetric, and a guard that is wrong in the cheap direction stays in.
Count the thing you are afraid of
The deeper mistake in the cap was not the number. It was the unit.
I was afraid of a rule touching too much of the catalogue, and I measured nodes in a graph. Those are different quantities, and the gap between them is exactly where the class words live.
The job that ranks what to decide next does not make that mistake, which is how the contrast became obvious. It ranks candidates by occurrences on real labels rather than by distinct id, because a decision inherits downwards:
measured over 6,000 real products
deciding the top 20 concepts: unassessed occurrences 58% -> 21%
deciding the top 200 ids: unassessed occurrences -> 2.4%
A list ranked by distinct id would have spent the same model calls on the tail. The same unit, occurrences rather than nodes, is the one the cap should have been using all along, and the class-word set is the patch that stands in for it until the cap is rewritten to read prevalence.
The rest of this arrangement, including why a model is allowed to classify but never to write a sentence, is in AI proposes, the engine disposes and a curation model may decide what an additive is, and may never write the sentence you read. The other direction of the same risk, a prompt whose menu of options quietly steers ingredients out of the rule graph, is the parent list that offered "vegetable" and not "onion".
See what a decided ingredient looks like
Each public answer page runs the production engine over the live taxonomy and rule maps, so it shows you the end state of this pipeline for one ingredient:
- Is agave syrup low FODMAP?
- Is acesulfame K ok with IBD?
- Does e330 cause reflux?
- The full index: munchable.app/answers
Then open app.munchable.app, choose a condition, and look up a heavily processed product. Notice what the result does not say. There is no caution that reads "contains an emulsifier", because the word on its own is not a finding, and keeping it from becoming one is what the guard above is for. The conditions themselves are listed on munchable.app/conditions.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.