Files
such.software/apps/web/content/blog/vegan-iq-data-pipeline.mdx
T
jwinterm d1d56ef744 fix(web): fail closed on Turnstile, keep the social card, derive the sitemap
Five things that were each one line of wrong and are cheap to hold shut.

Turnstile failed OPEN. `lib/turnstile.ts` returned true whenever
TURNSTILE_SECRET_KEY was unset, and logged the visitor's token while doing it.
Lose that variable in a deploy and the contact form quietly stops checking
anything, the only trace a warning nobody reads. Missing configuration is now a
refusal; the dev bypass survives as an explicit TURNSTILE_DEV_BYPASS=1 that a
production build ignores, rather than as a side effect of an empty variable. The
caller now distinguishes "you failed the check" from "we cannot run the check",
because telling a real visitor to refresh, when the server has no key, sends them
round a loop that cannot succeed.

The social card was being dropped by the pages that cared enough to write their
own. Next merges metadata shallowly, so a page exporting `openGraph: { title,
description }` replaces the parent's whole block, image included. Seventeen pages
did that; five had an opengraph-image.tsx to fall back on and twelve shared as a
bare grey rectangle. `pageMetadata()` takes the parts that differ and fills in
the parts that must not go missing. Built output goes from 23/51 pages carrying
og:image and 12/51 carrying twitter:image to 51/51 on both. Canonical was already
51/51 and stays there.

The sitemap was a hand-kept list and had drifted nine routes behind the site,
among them /products/{vegan-iq,suchoice,bauhaus-echo,such-moon-launch}/privacy,
which Apple and Google fetch during store review. It now walks the route tree, so
a page is advertised because it exists. Dynamic segments still come from their
data (blog posts, service pillars), and /contact is excluded WITH its reason: it
redirects, and a sitemap should name the destination rather than the door.
Result: 37 static routes, up from 28, nothing removed.

`vegan-iq-data-pipeline.mdx` was `draft: true`, which kept the most substantial
post on the site out of the index, the sitemap and generateStaticParams.

And /contact was unreachable on a first visit. It redirects to /#contact, but the
splash owns the first screen and entering it pins scroll to 0, so every inbound
contact link played the intro and dropped the visitor at the top of the homepage
with the hash discarded. A fragment that names something real on the page now
skips the splash and is honoured once, on arrival.

Verified: pnpm build green, lint clean (8 pre-existing no-img-element warnings,
none from this change), og/twitter/canonical counted in the built HTML.
2026-09-05 23:40:46 -04:00

200 lines
11 KiB
Plaintext

---
title: "Building a Data-Driven Trivia Engine: No LLMs, Just Verified Facts"
date: "2026-04-09"
excerpt: "How we built Vegan IQ's question pipeline: deterministic generation from USDA, Oxford, and FAOSTAT data, quality filtering from 2,461 to 1,066 questions, and a greedy anti-repetition algorithm that makes every quiz feel fresh."
draft: false
---
Vegan IQ ships with 1,066 trivia questions. None of them were written by an LLM. Every question traces back to a specific data source: USDA FoodData Central, Poore & Nemecek (Science, 2018), NIH, or FAOSTAT. The pipeline that produces them is a deterministic Python script that generates questions from structured data, filters them for quality, and outputs a single JSON file that the Flutter app bundles as an asset.
This post covers the full pipeline: data sourcing, question generation, quality filtering, and the runtime pacing algorithm that prevents repetitive quizzes.
## The Data Layer
All source data lives in a single Python module (`food_data.py`, ~2,800 lines). No API calls at generation time; everything is embedded as Python dicts and lists, verified against the original sources.
### Nutritional Data (USDA FoodData Central)
69 foods (56 vegan, 13 animal products), each with per-100g values for calories, protein, fat, carbs, fiber, iron, calcium, and vitamin C. The animal products exist so the app can generate fair head-to-head comparisons, such as "Which has more iron per 100g: hemp seeds or ground beef?", where the plant food wins on data.
### Environmental Impact (Poore & Nemecek, 2018)
The largest meta-analysis of food systems ever published: 38,700 farms across 119 countries. We use their global mean values for GHG emissions (kg CO2eq/kg), land use (m2/kg), water use (liters/kg), and eutrophication (gPO4eq/kg) across 40 food products.
### Other Sources
- **NIH Office of Dietary Supplements**: RDA values, bioavailability, deficiency risks for B12, vitamin D, omega-3, iron, calcium
- **FAOSTAT**: top-3 producing countries and global production percentages for 45+ foods
- **Oxford Companion to Food**: etymology and historical origins for 60+ foods
- **FDA labeling guidelines**: 38 animal-derived ingredients (carmine, casein, confectioners glaze, etc.)
- **Faunalytics + peer-reviewed studies**: animal cognition and behavior research
- **USDA NASS**: retail prices for protein cost-efficiency comparisons
## Question Generation
`generate_questions.py` (~2,000 lines) contains 22 generator functions. Each takes structured data and outputs a list of question dicts. The script seeds Python's random with `seed(42)` so every run produces identical output.
### Combinatorial Generators
These produce the bulk of raw questions by iterating over food combinations:
```python
# Simplified example of a per-100-calorie comparison generator
for combo in itertools.combinations(all_foods, 4):
if not _max_one_animal(combo):
continue
winner = max(combo, key=lambda f: f["protein_g"] / f["calories_kcal"] * 100)
if winner["protein_per_100cal"] < MINIMUM_MEANINGFUL["protein_g"]:
continue
# Generate question with 4 options, winner as correct answer
```
`_max_one_animal()` enforces at most one animal product per set of four options. This keeps the odds vegan-positive without making every question trivially obvious.
### MINIMUM_MEANINGFUL Thresholds
Early versions produced questions like "Which has the most protein per 100g?" where the winner had 2.5g. Technically correct. Completely uninteresting. The `MINIMUM_MEANINGFUL` dict gates all winners:
| Nutrient | Threshold |
|----------|-----------|
| Protein | >= 5g |
| Fiber | >= 1g |
| Iron | >= 1.5mg |
| Calcium | >= 30mg |
| Vitamin C | >= 5mg |
### Curated Generators
Not everything is combinatorial. Hidden ingredient questions ("Is carmine vegan?"), food history questions ("Where did tempeh originate?"), and the "Would You Believe" two-truths format are hand-structured data fed through simpler generators. These produce more varied, narrative-style questions.
### Vegan-Positive Framing
When an animal product wins a nutrition comparison (34 of 1,066 questions), the explanation is reframed with environmental context:
> "Chicken breast has 22.5g protein per 100g vs black beans at 8.9g. However, chicken costs 9.9 kg CO2eq, 12.2 m2 land, and 660L water per kg."
Eight phrasing variants are randomly selected. The `isAnimalCorrect` boolean on the question model flags these for the app's UI to use muted feedback ("Technically correct..." instead of "Nailed it!").
## From 2,461 to 1,066: The Curation Step
Raw generation produces 2,461 questions. The quality filter cuts this to 1,066, a 43% acceptance rate.
### Subcategory Caps
The combinatorial generators are prolific. Without caps, "Which has the most protein per 100 calories?" would appear 160+ times with different food options. Same question text, different foods. After 3-4 of these in a quiz, it feels like a spreadsheet.
Each template subcategory is capped:
- Per-nutrient comparisons: 25 each
- Binary showdowns: 35-50 each
- Environment comparisons: 25 each
This dropped the template-to-curated ratio from 77/23 to 43/57. In a 15-question quiz, roughly 6 are templated comparisons and 9 are diverse curated questions.
### Scenario-Based Phrasings
Even within caps, the same question text repeated 14+ times across a pool gets stale. Five phrasing variants per template type add surface variety:
- "Which food has the most protein per 100 calories?"
- "Calorie for calorie, which food packs the most protein?"
- "You're counting calories but need maximum protein. Which delivers the most per 100 cal?"
Same underlying data, different framing. Unique question texts went from ~30 to 658 across the pool.
### primary_food Deduplication
426 questions are tagged with a `primary_food` field (e.g., "tempeh", "mango"). The runtime pacing algorithm penalizes (-100) same-food questions within a single quiz. This prevents "Where did mango originate?" followed by "Which country produces the most mangoes?"
### Plant Milk Exclusion
Plant milks are ~90% water by weight. Per-kg environmental comparisons are misleading: oat milk looks absurdly efficient compared to almonds, but that's comparing a beverage to a solid food. A `_ENV_PER_KG_EXCLUDE` set filters all plant milks from the 5 environmental per-kg generators. Plant milks still appear in nutrition per-100g questions where the comparison is fair.
## Runtime: The Pacing Algorithm
Good questions don't matter if the quiz feels monotonous. The `QuestionService` in the Flutter app implements a greedy scoring algorithm that selects questions one at a time, penalizing candidates that would create repetitive sequences.
### Four Levels of Deduplication
1. **Session-level (hard block):** Questions shown in the current app session are never repeated. `_sessionSeenIds` is a `Set<int>` reset on app restart.
2. **Cross-session (soft penalty):** The last 300 question IDs are persisted in `SharedPreferences`. Questions in this set receive a -15 penalty but aren't blocked; you'll eventually cycle through the full pool.
3. **Subcategory spacing:** No two adjacent questions can share a subcategory (-100 penalty). Two questions back: -60. Three back: -30.
4. **Category spacing (all-categories mode):** If 3 of the last 5 questions share a category: -80 penalty. Same category as previous question: -40.
### Scoring Candidates
For each position in the quiz, the algorithm scores up to 60 candidates from the pool and picks the highest-scoring one:
| Condition | Penalty |
|-----------|---------|
| Same subcategory as previous | -100 |
| Same question text as previous | -120 |
| Same `primaryFood` anywhere in sequence | -100 |
| Same category in 3 of last 5 | -80 |
| 3+ overlapping options with previous | -60 |
| Same subcategory 2 back | -60 |
| Same question text in last 5 | -50 |
| Same category as previous | -40 |
| Same subcategory 3 back | -30 |
| 2 overlapping options | -20 |
| Same category 2 of last 5 | -20 |
| In cross-session recent set | -15 |
| Switch between binary/multi-choice | +10 |
The +10 bonus for format switching means quizzes alternate between swipe (true/false) and tap (multiple choice) questions, which keeps the interaction varied.
### Pool Partitioning
Before scoring, the pool is split into three tiers:
1. **Preferred:** Not seen this session AND not in recent-300
2. **Fallback:** In recent-300 (soft deprioritized via -15)
3. **Emergency:** Seen this session (only used if pools are exhausted)
Preferred candidates are scored first. With 1,066 questions and 300 recent IDs tracked, the preferred pool stays above 700 for most users, enough for 40+ unique 15-question quizzes before any soft repeats.
### Spaced Repetition
Up to 3 questions the user previously answered incorrectly are pre-seeded into each quiz. These come from a missed-questions pool (max 200 entries) tracked in `SharedPreferences`, prioritizing questions missed 1-2 days ago. Correctly answering a review question removes it from the pool.
### Complexity
The greedy approach runs in O(count x min(poolSize, 60)), well under a millisecond for 15-20 question quizzes on any modern phone. No noticeable latency between tapping a category and seeing the first question.
## Output
The pipeline outputs a single `questions.json` (623 KB, 1,066 entries). The Flutter app bundles this as an asset and caches it in memory on first load. No network requests, no server dependency, fully offline.
```json
{
"id": 42,
"category": "nutrition",
"subcategory": "protein_per_100cal",
"question": "Which has more protein per 100 calories: Soybeans or Cheddar Cheese?",
"options": ["Soybeans", "Cheddar Cheese"],
"correct_index": 0,
"explanation": "Soybeans: 9.6g per 100 cal vs Cheddar Cheese: 6.2g per 100 cal.",
"data_source": "USDA FoodData Central (SR Legacy)",
"primary_food": null,
"is_animal_correct": false
}
```
Every question carries its citation in `data_source`. The app displays this in the explanation screen after the user answers. No trust-me-bro: you can verify any answer against the original dataset.
## What We Learned
**Template fatigue is real.** The first version had 77% templated questions. Users noticed by quiz 3. Subcategory caps and phrasing variants were the fix.
**Per-kg comparisons lie.** Comparing the environmental footprint of almonds (a solid) to oat milk (90% water) per kilogram is misleading. We had to exclude plant milks from per-kg environmental generators entirely.
**The pacing algorithm matters more than the questions.** A great pool of 1,066 questions can feel repetitive if the selection is random. The greedy scoring approach, with its layered penalties for subcategory, category, text, option overlap, and primary food, is what makes each quiz feel curated.
**Deterministic generation is underrated.** `seed(42)` means every team member, CI run, and release build produces the exact same question set. No drift, no surprises, no "the questions changed and I don't know why."
The full pipeline, data → generators → quality filter → JSON → pacing engine, runs in under 2 seconds on a laptop. The output is a single file that ships with the app. No servers, no APIs, no LLMs. Just data.
See the [Vegan IQ product page](/products/vegan-iq) for more.