While auditing our parser pipeline, we found that 7,576 rows in our food catalogue had fake calorie counts. That is 47% of the calorie column. Worse, 2,012 live landing pages were printing these numbers as measured data, and our portion calculator was computing feeding grams from them. A migration had silently filled missing data with hardcoded defaults.
What we tried
We ran a database migration to estimate energy values for products missing macro data. Step 1 derived energy from protein and fat using a modified Atwater calculation. That was defensible. Step 2 was the problem. It stamped a bare constant on every row Step 1 skipped.
-- Step 2: Fallback by food_type for products without macro data
UPDATE food_products
SET energy_kcal_per_100g = CASE food_type
WHEN 'wet' THEN 85
WHEN 'raw' THEN 120
ELSE 360 -- dry (default)
END
WHERE energy_kcal_per_100g IS NULL;
The column had no provenance field to track which rows were estimated.
What actually happened
Nothing downstream could tell a guess from a label. Because the constant for dry food was 360 kcal, our rule warning users when large-breed puppy food exceeds 400 kcal/100g could never fire for those rows. The guess suppressed the alert, and a miss looked like a pass. Furthermore, 2,413 of the stamped rows were treats.
This compounded with another bug. Our food type classifier ended in an unconditional return of "dry". Any product whose URL and name lacked a recipe word, like zooplus bundle SKUs or French listings saying "Boite 400 g", became dry. This hit 59% of the catalogue, or 9,553 out of 16,135 products. An unknown type became dry, which got assigned 360 kcal. For an actually-wet food, this was a 4x energy overestimate, resulting in portion advice that was 4x too small.
We also had two drifting copies of our longevity score. The JavaScript build script and the Go backend matcher were the same algorithm twice, but they disagreed on 5,101 products. 3,017 were in a different tier entirely. The Go copy fed the barcode scanner, so 223 wet foods the site called Good or Excellent were scanned as "Basic nutrition profile". We also found that 62 of the 100 longevity points were regexes over the ingredients declaration. With no ingredients text, the ceiling was ~39 even for an excellent food, yet pages printed scores like "Basic, 24/100" as a verdict. That affected 8,476 of 16,121 pages.
The fix
We deployed a new migration that cleared 7,538 of the stamped rows and kept the 38 where Atwater agreed within 12%. We added an energyEstimated label in our Go backend to gate the puppy rule and label estimated portions. We fixed the JS and Go score drift by making them identical rule-for-rule, pinned by tests.
We also removed the claim that foods were "Scored against FEDIAF and WSAVA standards" from 268 pages. WSAVA publishes no nutrient profile, so nothing can be scored against it. We implemented a real check for protein and fat versus FEDIAF minima on a dry-matter basis, covering two of the roughly forty nutrients FEDIAF specifies, and updated the copy to reflect this.
What is still open
About 143 already-stored rows are still mistyped. We deliberately did not mass-correct the food type because it feeds the matcher, the landing hubs and the energy defaults. Running the moisture backfill self-corrects their score, since real moisture overrides the food type assumption.
If you are building something similar
- Every derived column needs provenance. Track whether a value is measured or estimated.
- A default that looks like data is worse than NULL. It will silently break downstream logic.
- A simulation of two implementations is not a comparison of them. Read both code paths.
- Check your export SELECT statements whenever a generator reads a new field. Missing columns return undefined without throwing an error.