Munchable tells someone with IBS, reflux or Crohn's whether a supermarket product is a good fit for them. The answer has to be right, and "the model said so" is not a defensible reason for telling a person with an inflammatory bowel condition that a product is fine.
So we use a language model heavily, and it is not allowed anywhere near a verdict. The rule we built everything around:
AI proposes, the engine disposes.
Every verdict a user sees comes out of a deterministic rules engine reading hand-checked rule maps. The model's entire job is to propose rows for those maps, offline, and every proposal has to survive a gauntlet of deterministic guards before it is allowed in. Here is what that actually looks like in code.
Constraint one: the model cannot write anything a user reads
This is the single highest-leverage decision in the whole design.
The model never sees an internal id, never writes an id, and never writes a sentence that reaches a human. It is shown an ingredient in plain English along with its position in our taxonomy, and it answers with enum values only:
/** One answer from the model. Enum values only; `none` means "no rule applies". */
export interface RuleProposal {
id: string;
fodmap: string; // a FODMAP group, or 'none'
fodmap_potent: boolean;
lactose: string; // high | moderate | low | trace | none
gerd: string; // a trigger code
ibd: string; // a category
ibd_substance: string;
gastroparesis: string; // a texture
confidence: number;
rationale: string; // for the audit log, never for the user
}
A closed set of enum values is checkable. Prose is not. When the model can only answer "moderate" or "high" or "none", validating its output is a set membership test rather than a judgement call, and there is no path by which a hallucinated sentence ends up on a screen in front of somebody deciding what to eat. The reason copy a user sees is derived from the ingredient id inside the engine, from text we wrote.
The rationale field exists purely so a human reviewing the audit log can see why the model said what it said. It is never rendered.
Guard one: a confidence floor
export const DEFAULT_MIN_CONFIDENCE = 0.8;
The cheap one, and not sufficient on its own, because a confidently wrong model is a well documented phenomenon. It filters the obvious noise so the interesting guards have less to do.
Guard two: generality, or "the job adds leaves, a human adds roots"
Our rules fire on an ingredient and everything beneath it in the taxonomy. So a rule placed on a broad concept is not one rule, it is hundreds.
/**
* An automated rule may not sit on a concept with more descendants than this.
* `en:tuber` (36), `en:nut` (107) and `en:seed` (94) pass; `en:root-vegetable`
* (153), `en:dairy` (387), `en:vegetable` (694) do not.
*/
export const MAX_DESCENDANTS = 150;
Put a lactose rule on en:dairy and you have just made a claim about 387 ingredients in one automated decision. Broad rules are real and useful. Our hand-written map deliberately puts a rule on en:cheese covering 191 cheeses, because a human thought about it. The point is not that broad rules are wrong, it is that a broad rule is not a decision to automate. The nightly job earns leaves. Roots need a person.
This one guard converts "the model was wrong once" from a catastrophe into a single bad row.
Guard three: lineage plausibility, the cheapest hallucination check there is
This is my favourite, because it costs zero model calls and catches the most dangerous class of error.
A claim has to make sense against our own taxonomy. "This is high-lactose dairy" is only credible for something that actually sits under en:dairy:
const LINEAGE: Record<string, readonly string[]> = {
'fodmap:lactose': ['en:dairy'],
'fodmap:fructan': ['en:vegetable', 'en:cereal', 'en:plant', 'en:fibre'],
'fodmap:gos': ['en:legume', 'en:nut', 'en:seed', 'en:plant'],
'fodmap:polyol': ['en:fruit', 'en:vegetable', 'en:sweetener', 'en:plant'],
'lactose:high': ['en:dairy'],
'lactose:moderate': ['en:dairy'],
// ...
};
If the model proposes a lactose load for something that is not a dairy product, we do not argue with it or ask it again. The proposal is structurally impossible and it is dropped.
Two details worth stealing:
Not every claim has a lineage. GERD triggers genuinely have no common ancestor. Coffee is a beverage, kimchi is a vegetable, chocolate is a cocoa product. An empty list means "no lineage constraint", and that map leans on generality and confidence instead. Faking a constraint that does not exist in the domain would just produce false rejections and teach everyone to ignore the guard.
Sometimes the id shape is the check. Our seed graph gives additive E-numbers no parents at all, so "is this an additive" cannot be asked of the hierarchy. But an additive's canonical id is its E-number, which makes a pattern match on the id a sound test. When the graph cannot answer a question, look for a different invariant in your data that can. You do not need a model call for a question your id scheme already answers.
Guard four: the bland list
/**
* Nearly every product contains these; a rule on one flags the catalogue.
*/
const BLAND_IDS: ReadonlySet<string> = HAND_CLEARED;
Water, salt and sugar are in almost everything. A rule on any of them does not flag an ingredient, it flags the entire product database, and the app becomes useless in one commit.
Note where this list lives: the engine owns it, not the curation job. The engine needs to know these are reviewed and cleared, not merely unscored, because "we have not looked at this yet" and "we looked and there is nothing to say" are different facts that must not be conflated. More on why that distinction is load bearing in a separate post about a bug it caused.
Guard five: silence is not consent
/**
* The model returned no item for this candidate at all. Nothing was
* decided, so the storage layer leaves the backlog row open to be asked
* again next run, like a batch whose call failed, instead of closing it.
*/
unanswered?: true;
If you ask for 50 items and get 47, the temptation is to treat the 3 missing ones as "nothing to say" and close them. That is wrong. A missing answer is a missing answer. Those rows stay open and get asked again. Conflating "no" with "no response" is how items vanish from a backlog without anyone ever deciding anything about them.
And the data the model never sees
A curation candidate carries a canonical id, some counts, and the ids of ingredients that appeared on the same labels:
export interface RuleCandidate {
id: string;
count: number;
weight?: number;
contexts: string[]; // co-occurring canonical ids, never raw text
}
No user id. No raw label text. No barcode. The curation pipeline is a data job about ingredients, and it does not need to know that a particular person scanned a particular thing, so it is never given the opportunity.
Why this is the right trade
We get the thing models are genuinely excellent at, which is broad coverage of messy real world vocabulary, and we keep the thing models cannot give us, which is a verdict you can trace, test and defend. Every one of those guards is a pure function. They run in unit tests, they run in milliseconds, and they cost nothing per item.
If you are putting a model anywhere near a decision that matters, the question worth asking is not "how do I make the model more accurate". It is "what is the narrowest possible thing I can let it say, and what deterministic check can catch it when it is wrong?"
You can see the output end of this at munchable.app/conditions, which explains what the engine actually checks for each of the seven conditions. Or go straight to a verdict: is onion low FODMAP? is generated by the same rules engine that runs in the app, not written by hand. The app itself is free to try if you want to point it at your own cupboard.
Top comments (0)