Deep dive: shopping list aggregation
When the @gram-lang/kitchen package processes an Abstract Syntax Tree (AST), one of its core responsibilities is generating the shopping list.
This is not a simple concatenation of ingredients. The compiler performs a complex aggregation process to ensure the list is physically accurate and optimized for purchasing.
The aggregation pipeline
Section titled “The aggregation pipeline”When a recipe (or a batch of multiple recipes) is passed to the Kitchen for shopping list generation, the following pipeline executes:
flowchart TD
Raw["Raw Ingredients (.gram)"] --> KitchenGroup["@gram-lang/kitchen<br/>Group by Raw ID & Identical Units"]
KitchenGroup --> HasDB{Ingredient DB Supplied?}
HasDB -- Yes --> AliasRes["@gram-lang/analyzer<br/>Alias Resolution (e.g., beurre ➔ butter)"]
HasDB -- No --> UnitsCheck
AliasRes --> UnitsCheck{Identical Units?}
UnitsCheck -- Yes --> SumUnits["Direct Quantity Summation"]
UnitsCheck -- No (Mixed) --> DensityCheck{Density Available?}
DensityCheck -- Yes --> ConvertGram["Convert to Mass (grams) & Merge"]
DensityCheck -- No --> MultiUnit["Fallback: multiUnit=true<br/>(Cluster Entries Under Single Heading)"]
-
ID normalization and aliasing
Section titled “ID normalization and aliasing”@gram-lang/kitchenitself groups ingredients purely by the raw id it assigned during parsing (a slug of the name you wrote) — it has no access toingredients.yamland can’t know that@butterand@beurrerefer to the same ingredient. On its own, kitchen’s shopping list lists them as two separate entries.Alias resolution happens one layer up, in
@gram-lang/analyzer, which does have access to the ingredient database. Ifbeurreis listed as an alias ofbutteriningredients.yaml, the analyzer re-groups kitchen’s raw entries by this canonical id, merging@butterand@beurreinto a singlebutterentry. This step only runs when an ingredient database is supplied — without one (compiling with@gram-lang/kitchenalone, or a CLI command run without a database), aliases are not resolved and the two ingredients stay separate. -
Unit merging
Section titled “Unit merging”If the grouped ingredients share the same unit (e.g.,
100gand50g), they are simply summed (150g) — this happens directly in@gram-lang/kitchen, no database needed.If they have different units (e.g.,
100gand1 cup), summing them requires converting through mass, which — like aliasing — depends on the ingredient database and therefore happens in@gram-lang/analyzer. If every contributing quantity resolves to a mass (density known, from the database or a recipe-level override), the analyzer converts them all to grams and merges them into a single entry.Fallback: missing density. If the density can’t be resolved for at least one of the units involved, the analyzer can’t produce a single reliable mass total. Rather than guessing, it keeps the entries separate, renames them to the canonical id, and flags all of them with
multiUnit: true. Renderers use this flag to cluster the entries together under one heading (e.g. “⚠️ Mixed units”) instead of scattering them across the list — making it clear to the cook that they’re the same ingredient, without silently mis-adding incompatible units. -
Ghosting relative quantities
Section titled “Ghosting relative quantities”Relative quantities (like
@water{50% @&flour}) pose a unique problem for shopping lists, especially in multi-recipe batch processing.If you scale the batch, the water quantity depends on a flour quantity that might belong to a specific recipe. To prevent mathematical paradoxes, the Kitchen ghosts relative quantities in the shopping list. This means they are excluded from the main aggregation and listed separately as unresolvable dependencies, ensuring the static weights remain mathematically pure.
-
Resolving composites (MAX vs SUM)
Section titled “Resolving composites (MAX vs SUM)”The most complex part of the aggregation pipeline is handling Composite Ingredients (
<@).When multiple children point to the same parent, the compiler uses two distinct rules to figure out how many parents you need to buy:
- The MAX Rule (Non-Destructive):
If the children use different parts of the parent (e.g., lemon juice and lemon zest), you don’t need two lemons. The compiler takes the maximum equivalent parent mass required by either child. If you need 3 lemons worth of juice, but only 1 lemon worth of zest, the shopping list will say
3 lemons. - The SUM Rule (Destructive): If the children use the same part of the parent (e.g., chicken breast for a salad, and chicken breast for a soup), you cannot reuse the same chicken. The compiler sums the equivalent parent masses required by both children.
- The MAX Rule (Non-Destructive):
If the children use different parts of the parent (e.g., lemon juice and lemon zest), you don’t need two lemons. The compiler takes the maximum equivalent parent mass required by either child. If you need 3 lemons worth of juice, but only 1 lemon worth of zest, the shopping list will say
The final output
Section titled “The final output”The result is a highly optimized, deduplicated, and mathematically sound array of ShoppingItem objects, grouped by stable culinary categories (CATEGORY_KEYS from @gram-lang/i18n) and sorted according to localized category rules (getCategoryLabels(lang)), ready to be rendered in the terminal or a web UI.