Munchable reads an ingredients list and answers for seven gut conditions at once. Here is a page you can check, rendered by the same engine the phone runs: chicken and rice soup. It says the rules clear it for all seven conditions, and there are seven ingredients in it: chicken breast, carrot, white rice, ginger, rapeseed oil, parsley, salt.
Ask yourself what "clear" can honestly mean there. No rule fired. But "no rule fired" covers two completely different situations, and collapsing them is the mistake I want to write about.
The two-state trap
The naive model is binary: an ingredient either triggers a rule or it does not. Under that model, every word the engine has never considered silently counts as fine. Put a green tick on a product because of it and you have built a machine that is most confident precisely where it knows least.
The obvious correction is to disclose: list everything no rule reached. Do that and a bottle of water shows up as "not assessed", along with salt, sugar and flavouring, on nearly every product in the shop. You have not built trust, you have buried the one name that mattered under four that never could.
So there are three states, not two, and the engine has a predicate for each.
Known, scoreable, reviewed
isKnownTag is vocabulary: is this word an ingredient the taxonomy has an id for at all. A word no language knows stays unknown, and unknown words never get quietly dropped.
isScoreable is capability: can any rule say something about this id, either because it is a rule key itself or because it inherits one through the taxonomy graph. en:onion-powder is scoreable because it descends from en:onion.
isReviewed is the human one: has somebody decided about this ingredient, whether or not a rule came out of that decision.
export function isReviewed(id: string, ancestors: readonly string[] = ancestorsOf(id)): boolean {
if (isScoreable(id, ancestors)) return true;
const cleared = clearedSet();
if (cleared.has(id)) return true;
return ancestors.some((a) => cleared.has(a));
}
Three lines, and the order of them is load bearing: isScoreable is checked first, always, so nothing in a cleared list can ever shadow a rule that fires.
The complement is the useful part. Known, printed on the label, and not reviewed is exactly one thing: an ingredient our own data has not got to yet. That set is what the curation backlog is mined from. It is a work queue, not a warning.
Two cleared lists, deliberately not one
"Reviewed with nothing to say" is stored as two lists that a naive refactor would merge tomorrow.
One is the bland bucket: water, salt, flavouring, colour. The other is named whole foods: chicken breast, cod, egg.
They read identically from the engine's side, and they are kept apart because a second consumer needs one of them alone. The curation pipeline uses the bland bucket as its set of forbidden alias targets: whatever a model proposes, a health-relevant word must never end up aliased onto "flavouring", because that is how a trigger disappears into a shrug. Named whole foods are perfectly good alias targets, so they must not be in that set. Same shape, different job, and merging them would silently widen the forbidden set.
Adding a cleared food is a claim
Clearing an ingredient asserts something specific: it carries no FODMAP load, no lactose, nothing on the reflux list, no IBD marker, no texture problem for gastroparesis. Chicken breast is that. Its fat, which matters to one of the seven, is read from the nutrition figures rather than from the tag, so clearing the tag is not hiding it.
The instructive part is what is deliberately left out, because that is where the boundary of the idea is:
- Bread. Has no parents in our taxonomy, so it inherits nothing. Clear it and a wheat loaf passes as reviewed with its fructans invisible.
- Banana. Genuinely dose and ripeness dependent. A ripe one is not a neutral ingredient.
- Oats. Fine in an ordinary serving and not in a large one, which is a judgement a rule should make, with a threshold, rather than a clearing that makes the question go away.
The last two are honest admissions that the ingredient is still on the backlog. A clearing would close the ticket by declaring the question uninteresting, which is the one thing it must never be used for. The validator enforces the rest mechanically: a proposed clearing is rejected if any rule map scores that key (contradicts_rule), if it is a hand-written rule key, if the id is not in the taxonomy, if it is already cleared, or if it is a duplicate.
The dead zone this predicate created
The bug worth the post. isScoreable asked one question: is this id a key in one of the named rule maps. But one of the seven conditions reads fat density from its own table, which is not a rule map, so the predicate never saw it.
The result was a dead zone with two floors. About twenty of the commonest words on a label, vegetable oil, palm oil, butterfat, margarine, lard, mayonnaise, pastry, were reported to the user as not yet assessed by a rule that was in fact assessing them. And the curation guard refused them as hand rule keys, so no job could ever settle them either. An ingredient that is being scored, is told it is not, and cannot be fixed by the process that exists to fix it.
The fix is one clause. The lesson is that a predicate about "can anything say something about this" has to enumerate every source of a verdict, not every source that happens to look like a rule map.
The state that never reaches the user
Here is the decision that surprises people. The known-but-unreviewed set is computed on every scan, and the app shows none of it. It does not appear on the result screen and it does not cap a verdict.
There used to be a cap. Above a fifth of the ingredients unread, a good verdict was downgraded to "can't assess". The comment where it used to live explains why it went:
In practice it meant about a seventh of all products answered "can't assess" because of words like "voir bouteille" and "residu sec a 110 c" (label prose and OCR noise, not ingredients) while the words that decide a verdict were read perfectly well.
The rules are positive trigger lists. What protects somebody is those lists being complete in the languages labels are printed in, not a ratio computed over words nobody could read. An ingredient that triggers nothing needs no row and no reading.
And the cost, stated the way the code states it: on a label the engine could barely read, a product with an unread trigger now reads good rather than "can't assess". That is a real trade, so the mitigation has to be real too. It is trigger-list coverage, measured by its own command, with a test suite that fails the build if a word on the promise list would come back unassessed. Allergens sit outside all of this: they never pass through the verdict cap and can only ever add a warning.
So the three states are for us, not for the reader. The reader gets a verdict, and the three states are how we know whether we have earned the right to give them one.
You can see both ends of it live. The recipe library states a per-condition claim on every page, read at the strictest setting the app offers, and the soup at the top of this post is the clean case: every one of its seven ingredients is reviewed, so nothing about that claim rests on silence. The seven condition guides are the other end, and the honest reading of them is that they are the list that has to stay complete for any of this to hold. Our own backlog is the proof it is not finished: banana and oat flakes are still neither scored nor cleared, which is exactly the "nobody has decided yet" state this whole design exists to keep visible to us.
Top comments (0)