A recipe is a scan with no camera: one rules engine, three surfaces

typescript dev.to

Munchable scores a food label against seven gut conditions with a deterministic rules engine. This month it gained a recipe library: eighteen recipes, filtered per reader inside the app, and published as public pages at munchable.app/recipes. Both surfaces were cheap to build for one reason, and that reason is the subject of this post.

The rule: the engine's Product is derived, never authored

A barcode scan produces a Product: ingredient tags in label order, percentages where the pack prints them, fat and fibre per 100 g, a serving size. fitCheck takes that and returns a verdict with reasons.

A recipe is written for a cook, not for an engine:

export interface Recipe {
  slug: string;
  title: string;
  /** One calm sentence. Describes the food, never a health outcome. */
  summary: string;
  serves: number;
  minutes: number;
  mealTypes: readonly MealType[];
  /**
   * In the order the cook wants them, which is not the order the engine sees:
   * `toProduct` sorts its own copy by weight, the way a label is written.
   */
  ingredients: readonly RecipeIngredient[];
  steps: readonly string[];
}
Enter fullscreen mode Exit fullscreen mode

The bridge between the two is one function, and its doc comment carries the whole design: "exactly a Product, so fitCheck scores it with the same rules, thresholds and fail-closed logic as a scanned label. No recipe-specific scoring path exists, and none should."

export function toProduct(recipe: Recipe): Product {
  const total = totalGrams(recipe);
  const byWeight = [...recipe.ingredients].sort((a, b) => b.grams - a.grams);
  const { fat, fiber } = batchNutrition(recipe);

  const ingredients = byWeight.map((i) => ({
    id: identityTag(i),
    text: i.name,
    percentEstimate: (i.grams / total) * 100,
  }));

  return {
    barcode: `recipe:${recipe.slug}`,
    ingredientsTags: tags,
    ingredients,
    nutriments: { fat100g: (fat / total) * 100, fiber100g: (fiber / total) * 100 },
    serving: { sizeG: total / recipe.serves },
    statesTags: ['en:ingredients-completed'],
    unknownIngredientsN: 0,
    dataSource: 'recipe',
  };
}
Enter fullscreen mode Exit fullscreen mode

Three choices inside it are worth naming.

Weight order, not cooking order. A label lists ingredients heaviest first, and the engine's "is this a main ingredient" split reads position. Sorting by grams gives the recipe the same shape.

Exact percentages. On a scanned label, the primary-versus-minor decision is usually made from rank, because packs rarely print percentages. A recipe has grams for everything, so the 10 percent primary threshold is decided on fact. A sub-ingredient inherits its line's percentage, which slightly overstates it and therefore errs toward the stricter verdict.

en:ingredients-completed is honest here. For a captured label it is a claim the engine can never verify. For a recipe we wrote the list, so nothing is missing from it.

Nutrition is derived from the grams too, through a per-ingredient table, and a missing entry throws rather than defaulting to zero:

const entry = NUTRITION[tag];
if (!entry) {
  throw new Error(
    `No nutrition entry for ${tag} ("${ingredient.name}" in ${recipe.slug}). Add it to nutrition.ts.`,
  );
}
Enter fullscreen mode Exit fullscreen mode

The comment beside it says why: silently treating an unknown ingredient as fat-free would hand a bile acid malabsorption user a green verdict built on a gap, which is precisely the failure the engine's fail-closed rules exist to prevent.

Surface one: the app filters, it does not judge

Recipes ship in the app bundle and are scored on the phone, with the same call that scores a scan, against a profile that never leaves the device. There is no recipes endpoint and the module header says there must never be one, because filtering meals server side would mean sending the condition list to a server.

The filter is two gates, and the second is deliberately redundant:

function suits({ fit }: ScoredRecipe): boolean {
  if (fit.verdict !== 'good') return false;
  return !(fit.allergens?.hits ?? []).some((h) => h.level === 'contains');
}
Enter fullscreen mode Exit fullscreen mode

A contains hit already forces the verdict to avoid, so the second line looks unnecessary. It is there because that coupling lives in the engine and could be loosened there one day, and a recipe naming a declared allergy must never surface whatever any other layer decides.

The product decision that follows from filtering: there is no verdict on the recipe screen at all. A scan answers a question about a product in someone's hand, so it owes them the verdict and the reasons. A recipe list is a suggestion, and a suggestion the reader has to evaluate and reject is worse than one never shown. Recipes that do not suit are absent, not collapsed into a "worth a look" drawer and not counted.

Surface two: the public pages read at the strictest setting

A web visitor has no profile, so the public page cannot filter for them. Instead each recipe page lists which conditions the engine clears it for:

function suits(recipe: Recipe, condition: ConditionId): boolean {
  const fit = fitCheck(toProduct(recipe), {
    conditions: [condition],
    settings: { lactose: { sensitivity: 'high' } },
  });
  return fit.verdict === 'good';
}
Enter fullscreen mode Exit fullscreen mode

Lactose is read at the highest sensitivity the app offers, so the public claim can never be softer than what the app would do for the person who acts on it. A test pins that: "is never more optimistic than the app is for the reader who acts on it" re-runs every recipe at low, medium and high sensitivity and checks the page's claim against all three.

Open gentle rice congee and the page says it suits all seven conditions. Open baked salmon with dill potatoes and it lists four, with bile acid malabsorption missing, because the engine reads the fat per serving and that dish is over the line. The library is deliberately not all green. A list where everything fits teaches the reader nothing, and the honest spread is what makes the fitting recipes worth believing.

The recipes are grouped by meal rather than by condition, and that decision is recorded in the page source: seventeen of eighteen recipes suit reflux, seventeen suit low FODMAP, and the thinnest condition still has fourteen, so seven per-condition pages would be near duplicates competing for the same crawl budget. Collections become worth building when the library is large enough that the lists genuinely diverge.

The JSON-LD that leaves two fields out on purpose

Each recipe page emits a Recipe node with ingredients, numbered HowToStep instructions, a totalTime and a NutritionInformation block whose fat and fibre are derived from the quantities above rather than asserted. Two fields are absent by design.

image is missing, and it is the one thing standing between these pages and a rich result. Munchable has no food photography, and the fix for that is photographs. A typographic card passed off as a picture of the dish is schema that overstates the page, which is a manual-action risk.

suitableForDiet is missing because its vocabulary would invite a GlutenFreeDiet claim inferred from ingredient tags. A free-from claim is not something Munchable makes anywhere, least of all in machine-readable form where nobody reads the caveat next to it.

Try it

  • munchable.app/recipes is the public library. Every "Suits" line on it was produced by fitCheck, not typed.
  • Change the serving count on a recipe page and nothing about the verdict can move, because every quantity scales linearly and the per-100 g figures are unchanged. A test pins that too.
  • If you have the app, add or remove a condition in your profile and open Recipes. The list changes with no network request.

This is a companion to an earlier post on why our generated SEO pages run the production engine. Same principle, third surface.

Source: dev.to

arrow_back Back to Tutorials