Notes / N.14 Systems · working notes

Why LLMs should not calculate nutritional values

A model that writes “about 420 kcal” has produced a likely token, not a measurement. The numbers live in composition tables, weights and yields; the model may name and explain, and a deterministic layer does the arithmetic.

Reading time8 min
Stackfood-composition tables · deterministic arithmetic · tool use · schema-constrained output · unit tests
StatusWorking note

§ 01A plausible number is worse than none

When the IA Chef in Menucesta proposes a dish, the plan carries kilocalories and grams of protein per serving, and none of those figures was written by the model. That is a design decision, not a limitation waiting to be lifted. A language model produces the next token by likelihood; “165 kcal per 100 g of chicken breast” is in it because the string is common in the corpus, and what comes out is the central value of what it has read: the mechanics the training note covers. For a whole recipe it would have to multiply seven weights by seven per-100 g values, apply yields, sum and divide; what it does instead is emit a number that resembles the answers to similar questions. Sometimes that lands within ten per cent. Nothing in the output says when.

That is why a plausible number is worse than a refusal. A number in a plan is consumed by things that cannot judge it: the optimiser in N.13 enforces an energy band on it, a person with a clinical target eats to it, a label prints it. A wrong figure passes all three, because it is well formed and in range. A model that says “I do not compute nutrition” trips the correct branch, the deterministic one, at the cost of a tool call. So the rule in the product is absolute: no nutrient value in any model output, and a validator that rejects the output if one appears. The model keeps choosing, naming, adapting and explaining.

The model emits identifiers and grams; the deterministic layer turns them into numbers; only those numbers may come back into the model's words language model chooses and words ids and grams schema without nutrients tables · yields · Decimal deterministic arithmetic per serving with table version the only numbers it may quote
Fig. 01 · The model chooses foods and grams and writes the explanation; the code adds; the model may quote only what the code returned.

§ 02Where the numbers actually come from

A per-serving figure rests on four things, each with its own uncertainty. A food composition table: values per 100 g of edible portion for a named food in a named state, BEDCA in Spain, CIQUAL in France, McCance and Widdowson in the UK, USDA FoodData Central, harmonised across Europe by EuroFIR. Tables disagree by 5–15 % on ordinary foods because they sampled different products, and a table's energy value is usually not measured but computed from its macronutrients with conversion factors: in the EU those of Regulation 1169/2011, Annex XIV: 4 kcal/g for protein and carbohydrate, 9 for fat, 2 for fibre, 7 for alcohol. Weight: recipes are written in household measures, and “one onion” is 80 g or 200 g. Edible portion: a thigh on the bone loses about a fifth to the bone. Cooking: water leaves meat and enters rice, oil is absorbed in frying, some vitamins are lost: yield factors convert weights, retention factors scale nutrients, both published per food group and method.

The arithmetic is trivial: grams × edible portion × value / 100, summed, then divided. What is not trivial is keeping the state consistent: a raw weight against a raw entry, or a cooked weight against a cooked entry, never crossed. The table is one braise for four (chicken thigh, rice, soy sauce, vinegar, oil, onion, garlic) with illustrative per-100 g values in the range of public tables: chicken thigh raw 121 kcal and 19.7 g protein, cooked 179 and 24.8; white rice raw 365 and 7.1, cooked 130 and 2.7; yields of 0.70 for the chicken and 2.9 for the rice. Every row is a way the same recipe gets computed in the wild.

Methodkcal per servingProtein g per servingAgainst the baseline
Raw weights × raw entries (baseline)58646.5
Cooked weights × cooked entries60442.3+3 %
Raw weights × cooked entries52653.4–10 %
Cooked weights × raw entries1,03444.8+76 %

The two consistent methods agree within the tolerance of the tables; the gap in protein is retention and sampling. The two crossed methods give 526 and 1,034 kcal, and both read as reasonable for a plate of chicken and rice. That is the argument: the same recipe yields four confident numbers, three of them plausible, and only bookkeeping tells them apart. A model trained on text in which all four kinds of statement occur has no bookkeeping to consult.

§ 03The split: what the model may say

In the product the model's output is a document with nowhere to put a nutrient. It is validated against a JSON schema with additionalProperties: false, and the ingredient object has three fields: a food identifier, a raw weight in grams, and a preparation method. There is no kcal, no protein, no free-text quantity. The identifier must be one the model obtained from a search_food tool call in the same session: the validator keeps the ids each search returned and accepts no other, so an invented bedca:1234 never passes. Grams are bounded integers. The method comes from a closed list, because it selects the yield and retention factors later.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object", "additionalProperties": false,
  "required": ["title", "servings", "ingredients", "steps", "why"],
  "properties": {
    "title":    {"type": "string", "maxLength": 80},
    "servings": {"type": "integer", "minimum": 1, "maximum": 12},
    "ingredients": {
      "type": "array", "minItems": 1, "maxItems": 25,
      "items": {
        "type": "object", "additionalProperties": false,
        "required": ["food_id", "grams_raw", "prep"],
        "properties": {
          "food_id":   {"type": "string", "pattern": "^(bedca|usda):[0-9]{1,7}$"},
          "grams_raw": {"type": "integer", "minimum": 1, "maximum": 3000},
          "prep":      {"enum": ["raw", "boiled", "steamed", "roasted",
                                 "stewed", "fried", "grilled"]}
        }
      }
    },
    "steps": {"type": "array", "maxItems": 12,
              "items": {"type": "string", "maxLength": 240}},
    "why":   {"type": "string", "maxLength": 400}
  }
}

The model then calls compute_nutrition with the validated recipe, and only after that does it write. Its prose goes through a numeral check: every number followed by a unit must match a value in the tool result within display rounding, or be a count, a time or a temperature that appears in the steps. “About 580 kcal and 46 g of protein per serving” passes, because the tool said 586 and 46.5 and the display rule rounds to the nearest ten; “roughly 500 kcal” fails, and the turn is regenerated. It is the discipline of N.04: the schema bounds the effect, the tool results bound the numbers.

§ 04The arithmetic layer

The deterministic layer is short and boring. It uses Decimal throughout, because a float sum over twenty ingredients gives a different last digit depending on order, and a test against a golden value should not have to reason about that. Every value carries its unit in the column name (protein_g, sodium_mg); salt is derived from sodium with the factor 2.5 the Regulation prescribes rather than stored twice; and the table and the factor set each carry a version string that ends up in the result, so a plan computed last month can be recomputed and the difference explained.

from decimal import Decimal as D

NUTRIENTS = ("kcal", "protein_g", "carb_g", "fat_g", "fibre_g", "sodium_mg")
ATWATER = {"protein_g": 4, "carb_g": 4, "fat_g": 9, "fibre_g": 2}   # Reg. 1169/2011, Annex XIV

def compute_nutrition(recipe, table, factors):
    total = {k: D(0) for k in NUTRIENTS}
    cooked_g = D(0)
    for ing in recipe["ingredients"]:
        row = table.row(ing["food_id"])                  # per 100 g edible portion, raw state
        check_row(row)                                   # energy vs macros, mass balance
        g = D(ing["grams_raw"]) * row.edible_portion     # bone, peel, shell removed
        ret = factors.retention(row.group, ing["prep"])  # fraction of each nutrient kept
        for k in NUTRIENTS:
            total[k] += g * row[k] * ret[k] / 100
        cooked_g += g * factors.yield_(row.group, ing["prep"])   # weight only
    n = D(recipe["servings"])
    per = {k: (v / n).quantize(D("0.1")) for k, v in total.items()}
    per["salt_g"] = (per["sodium_mg"] * D("2.5") / 1000).quantize(D("0.01"))
    per["weight_g"] = (cooked_g / n).quantize(D("1"))
    return {"per_serving": per, "servings": recipe["servings"],
            "table_version": table.version, "factors_version": factors.version}

def check_row(row):
    kcal_from_macros = sum(row[k] * f for k, f in ATWATER.items())
    if abs(kcal_from_macros - row["kcal"]) > row["kcal"] * D("0.05") + 2:
        raise TableError(f"{row.id}: energy {row['kcal']} vs macros {kcal_from_macros}")
    if row["protein_g"] + row["carb_g"] + row["fat_g"] + row.water_g > 100:
        raise TableError(f"{row.id}: more than 100 g in 100 g")

Two consistency checks run inside the function, because they catch data errors as they enter: energy recomputed from the macronutrients with the Annex XIV factors must be within 5 % of the row's own energy value, and protein plus carbohydrate plus fat plus water must not exceed 100 g. A row that fails either is a transcription error, and the plan must fail rather than let 3,650 kcal per 100 g of rice reach a serving. What the function never does: estimate a missing value, fall back to a similar food, round before the division. A missing row surfaces to the model as a tool failure, and its only correct move is another search.

§ 05Tests that catch a drift

Three kinds of test guard the layer. Golden recipes: per-serving values computed by hand against a pinned table version, asserted within the larger of an absolute tolerance per nutrient and 2 %; when the table is upgraded the goldens are recomputed on purpose and the diff reviewed row by row. This catches a factor set that changes a yield for a food group, or an import that loads a “rice, cooked” entry under a raw id and moves one ingredient threefold. A property test over the catalogue: energy from macronutrients against reported energy, per-serving values times servings against the total. And a leak test on the model side: past turns, stored with the tool results they saw, replayed through the numeral check, so that a prompt revision after which the model prefaces the tool call with its own guess, “around 550 kcal, let me confirm”, is caught before it ships. That drift is behavioural, not data, and only the leak test sees it.

import re, json, pytest
from decimal import Decimal as D
from nutrition import compute_nutrition, load_table, load_factors

TABLE, FACTORS = load_table("bedca-2025.1"), load_factors("eurofir-2019")
GOLDEN = json.load(open("tests/golden/recipes.json"))     # hand-computed on those versions
TOL_ABS = {"kcal": D(3), "protein_g": D("0.5"), "carb_g": D("0.5"), "fat_g": D("0.5"),
           "fibre_g": D("0.3"), "sodium_mg": D(20), "salt_g": D("0.05"), "weight_g": D(5)}

@pytest.mark.parametrize("case", GOLDEN, ids=lambda c: c["recipe"]["title"])
def test_per_serving_matches_golden(case):
    out = compute_nutrition(case["recipe"], TABLE, FACTORS)
    assert out["table_version"] == case["table_version"]   # a silent upgrade fails here
    for k, want in case["per_serving"].items():
        got, want = out["per_serving"][k], D(want)
        tol = max(TOL_ABS[k], abs(want) * D("0.02"))
        assert abs(got - want) <= tol, f"{k}: {got} vs {want} (±{tol})"

NUM = re.compile(r"(?<![\w.])(\d+(?:[.,]\d+)?)\s*(kcal|kj|g|mg|µg)\b", re.I)

def test_prose_quotes_only_tool_numbers(replayed_turns):
    for turn in replayed_turns:            # past model outputs with the tool results they saw
        allowed = {float(v) for r in turn.tool_results for v in r["per_serving"].values()}
        for value, unit in NUM.findall(turn.text):
            v = float(value.replace(",", "."))
            assert any(abs(v - a) <= max(1, a * 0.02) for a in allowed), \
                f"{value} {unit} in the model's text has no tool result behind it"

§ 06Failure modes

The wrong numbers I have seen in recipe systems, generated or not, come from a short list of confusions. Each is caught somewhere in the pipeline, and never by the model.

  • Raw and cooked crossed. Rice takes on twice its weight in water, meat loses a third; a raw weight against a cooked entry, or the reverse, is the 526 versus 1,034 of the table. Fix: one state per row and a closed prep list.
  • Household measures. “One onion”, “a cup”, “a handful”. The resolver accepts grams or a catalogued piece with a stated mass, nothing else; “1 cup” fails validation and the model is asked for grams.
  • Edible portion ignored. Bone-in thigh, unpeeled banana, prawns in shell: 15–35 % of the purchased weight is not food. The edible fraction lives on the table row; the recipe carries purchased weight.
  • Sodium and salt, kJ and kcal. A table gives sodium in milligrams, a label wants salt in grams; EU labels show kJ and kcal. A model swaps them freely; the layer stores one and derives the other, and the numeral check reads the unit.
  • Per 100 g quoted as per serving. The commonest generated error: the model has memorised per-100 g values and presents them as the dish. Nothing in the text distinguishes them; the missing tool call does.
  • Absorbed frying oil. Fried foods pick up 5–15 % of their weight in oil that was never listed. The fried method adds it in the factor layer, with the assumption stated, so it reaches the total and the explanation.

§ 07What I measure

Four numbers, none of them a nutrient. The leak rate: model turns rejected by the numeral check per hundred, near zero, and the first signal that a prompt or model change altered behaviour. The unresolved-ingredient rate: recipes proposed with a food the search could not resolve: a catalogue backlog, not a model problem. The golden diff per table upgrade, by food group. And the share of plans ending within 1 % of the edge of their energy band; when it climbs, the optimiser in N.13 is straining against numbers that carry ±10 % of honest uncertainty, and it is the band that needs loosening. What I do not measure is how close the model's guess would have been, because it never makes one.

Next note

Scoring where a model belongs: a use-case ledger with a cost per case and a risk class

Read it →
← SANIX Written from work described at a high level · no client data