Munchable reads a food label and answers whether the product suits the gut conditions you manage. On top of those conditions there is a shopping filter: a list of things you said you would rather not buy. Seven of its ten preferences read the ingredient list. Three read the nutrition panel, and those three needed a number for the word "high".
Inventing that number was briefly tempting and would have been indefensible. So we did not.
/**
* The "high" thresholds, grams per 100 g, from the UK FSA/DHSC front-of-pack
* nutrition labelling guidance, the red band of the traffic-light scheme.
*
* A published, citable standard on purpose. Munchable does not get to invent
* what "high in sugar" means: the number a shopper has already seen on the
* front of thousands of packs is the number this filter should agree with.
*/
export const HEALTH_HIGH_SUGARS_100G = 22.5;
export const HEALTH_HIGH_SALT_100G = 1.5;
export const HEALTH_HIGH_SATURATED_FAT_100G = 5;
Three constants, one citation, and no defending of our own arithmetic to anybody. If a user disagrees with where the line sits, they are disagreeing with the same scheme that coloured the front of the pack in their other hand, and that is a much better argument for them to be having than one with us.
Per 100 g, because the alternative drags a whole new error term in
The guidance is expressed per 100 g, the catalogue stores nutriments per 100 g, and the comparison therefore happens per 100 g.
The appealing alternative is per serving, because that is what somebody eats. It is also an estimate on top of an estimate: serving sizes are missing on plenty of rows, they are declared inconsistently where they exist, and a product that is "high" per serving only because the serving is generous is a claim about the pack's marketing rather than the food. Per serving would have needed its own confidence treatment, and this layer is a preference that should not be carrying a confidence model at all.
One honest gap, named in the same comment: the guidance sets lower thresholds for drinks, and the catalogue does not record whether a product is one. So the food bands apply throughout. That errs towards saying nothing about a sugary squash rather than towards flagging it for the sugar a solid food would have to carry. Of the two mistakes available, the quiet one belongs to a filter whose job is to speak up only when it is sure.
The columns did not exist until the feature did
The three figures were not stored before this.
Our column discipline is that nothing is kept which nothing reads, and until this filter existed those three were provably dead: the engine reads fat and fibre for the conditions, and nothing anywhere read sugar, salt or saturated fat. Dead columns are not free. They ride in every payload the phone downloads and caches, they have to be migrated, and they come with an implicit promise that something is maintaining them.
Adding them was cheap for one structural reason: nutriments are a single jsonb column, so widening the shape was a type change rather than a migration. The label extraction had always read all five numbers off the nutrition photo, so every capture from this release onward carries them without asking the user for anything new.
And on most rows, the nutrient half says nothing
Here is the part that would be easy to hide. The imported rows do not carry these three figures and never will. They were imported before the columns existed, and there is no import left to run again, by design.
So on an imported row, the nutrient half of the filter produces nothing at all. The implementation is one line of intent:
for (const [preference, value, threshold, key] of nutrients) {
if (!on.has(preference) || value === undefined || value < threshold) continue;
// ...
}
A missing figure and a figure under the threshold take the same branch. The filter does not say "sugar: could not check". It does not list what it did not know. It says nothing.
That is a deliberate copy decision, not laziness about the empty state. Most rows are in that state today, and a filter that opened with three lines of what it could not assess would bury the part that works under a recital of its own gaps. We have deleted a ratio that made a seventh of all scans say "can't assess" for the same reason: an honest-sounding caveat that fires constantly is read as noise within a day, and then nothing it says is read at all.
The sugar question still gets answered on most packs, from the other direction. One of the ingredient-driven preferences covers added sugar syrups, and "glucose-fructose syrup" is printed in the ingredient list whether or not anybody ever photographed the panel. The two halves of the filter cover for each other, which is why the thin one can afford to be silent.
The findings are short on purpose
When a nutrient band does fire, the whole line is this:
const grams = value >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
byPreference.set(preference, [{
preference,
message: `High in ${HEALTH_NUTRIENT_LABEL[preference]} (${grams} g per 100 g)`,
detail: { [key]: value, threshold },
}]);
"High in sugar (34 g per 100 g)". The number is there so the reader can disagree with us. The rounding switches at ten grams, because one decimal place matters at 1.8 g of salt and is noise at 34.2 g of sugar.
There is no caveat under it. No "informational only", no "always check the pack", no explanation of how confident we are. The two layers either side of this one do hedge: the allergen layer hedges because a missed warning is a safety failure, and the condition rule sets hedge because their evidence is genuinely conditional. This layer answers a question the reader wrote themselves. They asked not to buy high-sugar food, the pack is over the published line, and saying so is the entire job. A softened disclaimer under every line would only make the feature read as unsure of itself.
Where the findings come from and who is allowed to write the words is a separate arrangement: the copy is derived in the engine from an id and a template, never stored as prose. And what a finding is allowed to do to the verdict, which is at most knock a green down to a caution, is the third lane that is not allowed to argue with the rules engine.
The ordering is a coverage statement
The ten preferences are declared in one array, and findings come back in that order:
export const HEALTH_PREFERENCE_IDS = [
'preservatives', 'colours', 'sweeteners', 'flavourings',
'hydrogenated-fat', 'added-sugar', 'emulsifiers',
'high-sugar', 'high-salt', 'high-saturated-fat',
] as const;
Additive-driven first, nutrient-driven last. Not alphabetical, and not by severity, because this filter does not rank severity. The additive preferences answer on any label with an ingredient list, which is most of the catalogue; the nutrient ones answer only where a panel was read. Sorting by how often a preference can say anything means the rows most likely to be populated are the rows a user reads first.
A product also always reads the same way round, whatever order its label happened to print things in, because the order comes from this array rather than from the pack.
Try it on a real pack
Open app.munchable.app in a browser. It is the same app as the phone build, so no install:
- Pick a condition during onboarding, then switch the shopping filter on. It arrives with nine of the ten preferences selected; emulsifiers is the one left off, deliberately.
- Look up something heavily processed, a flavoured crisp or a chocolate biscuit.
- Read the shopping row under the verdict. Every additive line names a substance and what it is, and stops. Any nutrient line carries its own number.
- Then look for what is not there. No line about a figure we did not have, and no line about an additive your preferences do not cover.
The conditions the verdict itself comes from are listed at munchable.app/conditions, and the public ingredient answer pages run the same engine over the same data, including the additive ones such as is acesulfame K ok with IBD.
Top comments (0)