Munchable reads a packaged food's label and answers whether it fits you. There are three quite different reasons a product might come back marked, and for a while they all wanted to live in the same function.
- Your conditions. IBS, reflux, lactose intolerance and four more, each with its own rule set drawn from clinical guidance: munchable.app/conditions.
- Your declared allergies. Safety critical, and a different kind of claim entirely.
- What you would simply rather not buy. Artificial colours, sweeteners, hydrogenated fat, very high salt.
They look like three instances of the same thing: match ingredients, emit findings. They are not, and the thing that separates them is not their data. It is what each one is permitted to do to the final answer, and who is allowed to write the words the user reads.
Powers, written down as a table
| Layer | Can mark a product | Can clear a product | Register of its copy |
|---|---|---|---|
| Condition rule sets | Yes, up to avoid | Yes, this is the verdict | Hedged, cites how strong the evidence is |
| Allergens | Yes, warn only | Never | Careful: "not mentioned" is not "free from" |
| Preferences | Yes, but never past caution | Never | Plain: names what is there, then stops |
Every awkward decision in this part of the codebase falls out of that table.
The preference layer can only add
The healthy shopping filter is the newest of the three, and it is the one most at risk of quietly becoming a fourth verdict. Its module header is mostly a list of things it is not allowed to do:
* - it runs only for a profile that switched it on, and only for the
* preferences that profile actually holds;
* - it only ever ADDS findings. It cannot clear a product and it cannot
* force an avoid, because nothing in here is a hazard: the strongest thing
* it can do is keep a product off green;
* - it states what it found and stops.
"The strongest thing it can do is keep a product off green" is the sentence that made the design obvious. A colouring is not a hazard. Somebody who ticked "no artificial colours" has expressed a shopping preference, not a diagnosis, and an app that answers Avoid because a birthday cake contains a colour has stopped being useful and started being a scold.
Mechanically, the layer returns its own value. It is not merged into the verdict, and the verdict does not take it as an input:
const verdict = checkProduct(product, profile.conditions);
const allergens = checkAllergens(product, profile.allergens);
const health = checkHealthy(product, profile.healthPreferences);
Three calls, three results, drawn as three separate rows on the result screen. There is no combining step, because a combining step is exactly where "keep it off green" would slowly become "make it red".
The quiet state is where these layers really differ
Anyone can get the positive case right. The interesting case is when a layer finds nothing, because that is where it is tempting to say something reassuring.
The preference layer says this:
/** The row title: the finding count, or the quiet state. */
export function healthTitle(check: HealthCheck): string {
const n = check.hits.length;
if (n === 0) return 'Nothing you asked about';
return n === 1 ? '1 thing you asked about' : `${n} things you asked about`;
}
/** The word on the right of the row. Never a verdict word. */
export function healthLabel(check: HealthCheck): string {
return check.hits.length === 0 ? 'None found' : 'Found';
}
"Nothing you asked about", not "nothing to worry about". The first is a true statement about a specific list the reader chose. The second is a claim about the entire product, which this layer has no standing to make: it read an ingredients list and whatever nutrition figures the row happened to carry, and that is all.
The colour follows the same logic:
/**
* Caution amber for a finding, muted grey for none. Deliberately not green
* when nothing was found: this layer never clears a product, and a green tick
* beside "none found" would be a claim about the whole pack rather than about
* the handful of things the reader asked after.
*/
A green tick is the cheapest way to accidentally tell somebody a product is fine. Grey costs nothing and promises nothing.
Note also what the row does not do when a nutrition figure is missing. It says nothing about that nutrient rather than announcing it could not assess it. Most rows in a product catalogue carry a partial nutrition panel, and a filter that opens with three lines of what it does not know buries the part that works.
Contrast the allergen layer, where the hedging is mandatory and the reasoning is inverted: a missing mention must never read as "free from", because there the failure mode is a hospital visit rather than a disappointing biscuit. Same engine, opposite copy rules, and the reason the two are not shared is that a shared "caveat" helper would have dragged safety language onto a preference row and preference bluntness onto a safety row.
Who is allowed to write a sentence
The last separation is the one I would most encourage you to steal if you have a curated dataset with a model anywhere near it.
Our ingredient classification grows over time, partly by hand and partly from a curation job. That job can add facts: this id is an additive, of this class. It cannot add prose. The finished sentence a user reads is always assembled by the engine from fixed templates:
/**
* The plain name of an id for a finding: the hand map's `name` where there is
* one, otherwise derived from the id. An overlay row has no name, by design,
* so a curation model can never author a word the user reads.
*/
and
/**
* The finished sentence for one finding. Fixed templates, so the copy is a
* property of the engine and not of any data a job wrote.
*
* "Allura red AC (E129), an artificial colour"
* "Hydrogenated palm fat"
* "Maltodextrin, an added sugar"
*/
Three small copy rules are encoded in that one function. An E-number always gets its class named, because a number alone tells a reader nothing. A plain ingredient is left to speak for itself, because "Hydrogenated palm fat, a hydrogenated fat" says one thing twice. Added sugars are the deliberate exception, because "Dextrose" and "Maltodextrin" are precisely the words a label reaches for when it does not want to write sugar.
The rule underneath all of it: data proposes, the engine disposes. Rows in a table can widen what the app knows. Only code can widen what the app says.
The shape this leaves behind
- One engine package, three entry points, three result types.
- Layer powers asserted in types rather than in review comments: the preference result has no field that could express a verdict, so there is nothing to misuse.
- Copy registers kept deliberately unshared, because the hedging that makes an allergy row correct makes a preference row limp.
- Every user-visible sentence generated from a template in code, so growing the dataset cannot change the voice of the product.
You can see the condition layer's public reasoning per condition, including the evidence note each rule set carries, at munchable.app/conditions/gerd-reflux, and the additive-level pages the same classification feeds, such as munchable.app/does-e330-cause-reflux, which is a good example of a substance that turns up almost everywhere and therefore has to be handled with a very quiet voice. The app itself, where all three rows appear on one result screen, is at munchable.app.
If you have a product with a safety layer and a taste layer in the same pipeline, I would like to know whether you separated them by type or only by convention. We tried convention first, and it lasted about a week.
Top comments (0)