Deep Dive: Shopping List Aggregation
When the @gram/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
When a recipe (or a batch of multiple recipes) is passed to the Kitchen for shopping list generation, the following pipeline executes:
1. ID Normalization and Aliasing
@gram/kitchen itself groups ingredients purely by the raw id it assigned during parsing (a slug of the name you wrote) — it has no access to ingredients.yaml and can't know that @butter and @beurre refer 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/analyzer, which does have access to the ingredient database. If beurre is listed as an alias of butter in ingredients.yaml, the analyzer re-groups kitchen's raw entries by this canonical id, merging @butter and @beurre into a single butter entry. This step only runs when an ingredient database is supplied — without one (compiling with @gram/kitchen alone, or a CLI command run without a database), aliases are not resolved and the two ingredients stay separate.
2. Unit Merging
If the grouped ingredients share the same unit (e.g., 100g and 50g), they are simply summed (150g) — this happens directly in @gram/kitchen, no database needed.
If they have different units (e.g., 100g and 1 cup), summing them requires converting through mass, which — like aliasing — depends on the ingredient database and therefore happens in @gram/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.
3. 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.
4. 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 Final Output
The result is a highly optimized, deduplicated, and mathematically sound array of ShoppingItem objects, grouped by their culinary categories, ready to be rendered in the terminal or a web UI.