Most people who need an app like Munchable do not have one condition. They have reflux and IBS. Or lactose intolerance and bile acid malabsorption. The conditions arrive together, and they do not agree with each other about food.
Almond is the easy example. It is a caution for gastroparesis, because texture and fat matter there, and it is not flagged at all for low FODMAP. You can see both facts on the public pages: is almond OK with gastroparesis exists and says caution, and there is no almond page under low FODMAP at all, because those pages only exist for the ingredients that rule set flags.
So when somebody with both conditions scans a bag of almonds, there are two correct answers and the app has to show one verdict at the top of the screen. That reconciliation is one of the smallest functions in the engine and one of the most argued over.
Nothing is ever averaged
The combined verdict is the worst of the concrete per-condition tiers. Not the mean, not a score, not a weighting.
This sounds obvious until you see how often health apps do the other thing. A number lets you build a nice dial, and a dial makes a product that is "avoid" for one of your conditions and "good fit" for the other come out as a reassuring amber seven out of ten. There is no amount of good news about your reflux that makes the onion safe for your IBS. Worst wins, and the answer at the top of the screen is the one that keeps you out of trouble.
The per-condition verdicts are never thrown away either. The engine returns the array as well as the combined answer, and the result screen renders one expandable row per condition, so "why" is one tap from "what".
The rule that is applied to only one of the two
Here is the part I find genuinely interesting. There are two separate reasons the combined verdict refuses to go green, and only one of them is applied to the per-condition rows:
// Fail-closed: never render green on poor data or when a selected condition
// could not be assessed. The confidence half is `capVerdict`, shared with the
// surfaces that render per-condition verdicts without a combined one, so
// "when is green not allowed" is defined in exactly one place. The any-unknown
// half is combined-only: another condition being unreadable is no reason to
// downgrade a condition this product genuinely passes.
let verdict: VerdictValue = capVerdict(
concreteTiers.length === 0 ? 'unknown' : worstTier(concreteTiers),
confidence,
);
if (verdict === 'good' && anyUnknown) verdict = 'caution';
The confidence cap is universal. If the label data is too thin to trust, nothing goes green anywhere, and that judgement lives in one shared function rather than being re-implemented by every surface that shows a verdict. There are several such surfaces, including ones that show per-condition answers with no combined verdict above them at all, and the moment two of them disagree about when green is allowed, the app has started lying somewhere.
The any-unknown downgrade is the opposite. If you selected three conditions and one of them could not be assessed on this product, the headline verdict drops from "good fit" to "caution", because the headline is a claim about all of your conditions at once and one of them has no answer. But the row for the condition that genuinely passed stays green. The unreadable condition is not evidence against it, and greying out a true answer because a different question went unanswered would be a small, constant dishonesty, repeated on every scan.
Rules that look like they should apply uniformly usually have one scope where they are true, and finding it is most of the work.
A disagreement is a thing we render
Worst-wins is correct and it destroys information. The user is told to put the box back, and never learns that one of the two reasons they opened the app was perfectly happy with it.
So a disagreement is its own object, computed only when both sides exist:
function detectConflicts(perCondition: readonly ConditionVerdict[]): Conflict[] {
const goodFor = perCondition.filter((c) => c.verdict === 'good').map((c) => c.condition);
const flagged = perCondition.filter((c) => c.verdict === 'caution' || c.verdict === 'avoid');
if (goodFor.length === 0 || flagged.length === 0) return [];
const allFlaggedReasons = flagged.flatMap((c) => c.reasons);
const topReason = allFlaggedReasons.find((r) => r.tier === 'avoid') ?? allFlaggedReasons[0];
const because = topReason ? ` (${topReason.message.toLowerCase()})` : '';
...
}
Three decisions are packed into those few lines.
A conflict requires at least one happy condition and at least one flagged one. Two flagged conditions are not in conflict, they are in agreement, and a banner announcing a disagreement that is not there is noise.
The sentence names one reason, not all of them, and it prefers an avoid-tier reason over a caution-tier one when both exist. The user gets "fits one, flagged for the other, because of this", and the full list is in the rows underneath. A conflict message that tried to be complete would be a paragraph, and a paragraph at the top of a verdict screen is read by nobody.
And the reason message is lowercased to be dropped into the middle of a sentence. Which is a reminder that every string in a rules engine has a grammatical context it was written for, and reusing it in another one is how you end up with "Fits reflux, but flagged for IBS (Contains onion.)." on a screen somebody is reading in a supermarket.
What the app will not do is rank your conditions for you. It knows you picked reflux and IBS; it has no idea which one ruined your last week. So it states the disagreement and lets you decide, which is also what the onboarding step promises before you have scanned anything:
Pick as many as apply. Munchable reconciles them into one verdict and shows you when they disagree.
The two layers that sit above all of this
Conditions are reconciled against each other. Two other things are not reconciled at all, they sit above the lot and can only push the verdict down.
Allergies are first. A declared allergen the data says is in the product makes the product an avoid whatever the conditions concluded, and precautionary "may contain" wording keeps it off green and no further. That layer has its own rules about what counts as evidence.
The healthy shopping filter is second, and its ceiling is deliberately lower: the furthest it can push a verdict is caution, never avoid. Somebody asking not to buy artificial colours has stated a preference, and a filter that turned that preference into the same red a declared peanut allergy produces would be the wrong answer to both questions. We wrote about adding that third lane without letting it argue with the rules engine.
Neither layer ever lifts a verdict. An empty allergy result is not a clean bill of health, and an empty filter result is not an endorsement.
What is a condition, and what is a dial
One more boundary that took a while to get right. Lactose sensitivity is not a condition, it is a parameter of one: the lactose rule set flags a product by how much lactose it typically carries, tuned to the sensitivity the user set. Separating the two would have produced two conditions that always agree, which is noise in the conflict logic, and would have made "how sensitive am I" into a diagnosis.
The test of the boundary is simple. If two things can disagree about a product, they are conditions. If one of them only ever changes where the other draws its line, it is a dial.
See it work
The seven condition guides are at munchable.app/conditions, each publishing its own rules and its own evidence caveat, and the index page states the reconciliation policy in a sentence: pick more than one and the app tells you where they disagree rather than quietly picking a winner.
For a real disagreement, compare is almond OK with gastroparesis with the absence of an almond page in the low FODMAP list on munchable.app/answers. For agreement, onion is flagged by both: is onion low FODMAP and does onion cause reflux.
And the app runs in a browser at app.munchable.app, where you can pick two conditions during onboarding, with no account, and read exactly what it promises to do with them.
Top comments (0)