Deep Dive: Scaling
Scaling a recipe sounds like a single multiplication, but Gram actually supports three distinct mechanisms that all end up producing one number — a scale factor — and then apply it consistently everywhere: the shopping list, every ingredient mention inside step text, and the recipe's portions metadata.
- Global Scaling — a flat factor (
--scale 2). - Reverse Scaling — a target quantity for one ingredient (
--scale flour=400g), from which the factor is derived. - Baker's Percentage — not really "scaling" at all, but a display transform that shows every ingredient as a percentage of a reference ingredient's mass.
All three are resolved and applied through a small, centralized ScaleEngine in @gram/kitchen, rather than each consumer (CLI, Playground) re-deriving its own rules.
Why a dedicated engine
Reverse scaling looks simple — divide the target quantity by the ingredient's current quantity — but a naive implementation gets a surprising number of things wrong silently:
- Scaling against
flour=1kgwhen the recipe is written in500gcomputes a factor of0.002instead of2if you don't reconcile units first. - Scaling against an ingredient marked fixed (
@=, never scales) produces a factor that doesn't actually describe what happens to the recipe. - Scaling against a relative quantity (
@water{70% @&flour}) is circular: its value is derived from another ingredient, so it can't also be the reference. - An ingredient split across two incompatible units in the same recipe only has a "primary" quantity in the aggregated shopping list — deriving a factor from it silently ignores the rest.
resolveScaleFactor() (exported from @gram/kitchen) validates all of this up front and throws a specific, typed error instead of returning a wrong number. Every consumer — the CLI's --scale flag today, the Playground tomorrow — calls the same function and gets the same guarantees for free.
The request/resolution contract
type ScaleRequest =
| { type: 'factor'; value: number }
| { type: 'target'; id: string; qty: number; unit: string | null };
function resolveScaleFactor(
compiled: CompilationResult | null,
request: ScaleRequest,
convertUnit?: (value: number, fromUnit: string, toUnit: string) => number | null,
): { factor: number; resolvedFrom: 'factor' | 'target'; targetId?: string; unitConverted?: boolean };Factor mode just validates the number is positive and finite —
compiledisn't even needed, so callers can validate a raw factor (e.g. from a Playground slider) without running a pipeline.Target mode looks the ingredient up in
compiled.shopping_list, runs it through the rejection rules below, reconciles units, and returns the derived factor.convertUnitis optional and injected by the caller.@gram/kitchendoesn't know about density, ingredient databases, or theUNIT_CONVERSIONStable at all — those live entirely in@gram/analyzer(which needs them anyway for mass standardization). The engine only ever calls it asconvertUnit(value, fromUnit, toUnit); without one, only exact unit matches (after alias normalization, e.g. "gram" → "g") succeed.@gram/analyzer's ownconvertUnit(value, fromUnit, toUnit, density?)takes an optional density (g/mL) as its 4th argument to bridge mass ↔ volume (e.g.flour=1Lagainst a recipe ing) — without one, it only converts within the same family (mass↔mass, volume↔volume), same as the bare table. The caller resolves that density once, viaresolveIngredientDensity(id, database, overrides)(checking a recipe's frontmatterdensities:override before the ingredient database), and binds it into a closure before handing it toresolveScaleFactor— the engine itself never sees an ingredient id, a database, or a density number, only a 3-argument function. The CLI and Playground both wire this up the same way.
Rejection rules (target mode)
| Error code | Why it's rejected | What the message suggests |
|---|---|---|
INGREDIENT_NOT_FOUND | No ingredient with that id in the shopping list | A "did you mean" suggestion against the recipe's real ingredient ids |
NESTED_ONLY_TARGET | Ingredient only exists inside a composite/sub-recipe's usage[], never at top level | Scale the parent composite instead — its own total (e.g. "2 lemons") is itself a valid target |
ALTERNATIVE_TARGET | The id is one option inside an alternative-ingredient group | Pick a different, unambiguous ingredient — the error lists the sibling options |
FIXED_INGREDIENT | Marked @=, or a TextQuantity like "a pinch" (which is fixed by definition) | Never scales, so can't describe a scale factor either |
RELATIVE_TARGET | Quantity is formula-derived (70% @&flour) | Scale the target ingredient (flour) instead — see Relative Quantities |
AMBIGUOUS_MULTI_UNIT | Same ingredient appears in two incompatible units across the recipe | The shopping list total can't be reduced to one number |
UNIT_MISMATCH | Units belong to different physical families (mass vs. volume) and no density could be resolved | Add a density via gram db enrich, declare one in the recipe's densities: frontmatter, or match units |
INVALID_FACTOR | Non-positive, non-finite, zero-quantity reference, etc. | — |
Immutability
applyScale(result, factor) — the function that actually multiplies every quantity — is a pure function: it structuredClones the input before mutating anything, and returns a new CompilationResult. Two things fall out of that for free:
- No compounding. Re-applying a different factor always starts from the same untouched original, so there's no risk of a factor being applied twice onto an already-scaled
portionsvalue. - No shared-reference corruption.
compile()itself defensively clones the AST'smetaobject before handing it to the result, so scaling a compiled recipe never mutates the parser's AST — safe even if a future caller (e.g. a Playground that caches the parsed AST and only recompiles on a scale-slider tick, instead of reparsing on every tick) reuses the same AST across multiple calls.
The resulting CompilationResult carries an honest scaleFactor field (default 1) recording how it relates to the unscaled original — this is what interactive UIs (like the renderer's HTML portions widget) read to compute a baseline, instead of a hidden/underscored field.
Baker's Percentage validation
Baker's Percentage isn't scaling, but it shares the same "don't compute a wrong number silently" philosophy. @gram/analyzer computes it as a bakersPercentage field per shopping-list item and per in-step ingredient mention — @gram/renderer just displays that precomputed value, it doesn't recompute it.
The reference ingredient (marked with @*, or forced via --bakers-reference) must be a physical anchor: if its own mass was itself derived from a relative quantity (conversionMethod === 'relative'), the analyzer refuses to use it as the 100% base — that would be circular — and instead emits an INVALID_BAKERS_REFERENCE warning on the result, leaving Baker's Math disabled for that run rather than showing percentages relative to a moving target.
If Baker's Math was requested (--bakers-math or --bakers-reference) but no @* modifier and no matching id were found at all, a NO_BAKERS_REFERENCE warning is emitted the same way — check result.warnings rather than a console log.
Aggregation stays consistent with mass. When the same ingredient is used more than once within a section (e.g. sugar added at two different steps), @gram/kitchen's aggregateSectionIngredients sums both normalizedMass and bakersPercentage across the repeated occurrences — since percentage is linear in mass for a fixed reference, the two always agree (e.g. two 100g/10% occurrences aggregate to 200g/20%, never a mismatched 200g/10%). Every renderer (@gram/renderer's HTML, Markdown, and print formatters, plus the CLI's gram cook TUI) reads this same precomputed, already-aggregated value — none of them recompute it themselves.
Consuming this from a new frontend
- Compile normally (
compile(), optionallyanalyze()if you want mass/Baker's data). - Build a
ScaleRequestfrom user input (a raw number, or an id + quantity + unit). - Call
resolveScaleFactor(compiled, request, convertUnit?). CatchScaleErrorand switch on.codefor UI-specific messaging — every error already carries a complete, user-facing.message. - Call
applyScale(compiled, resolution.factor)to get the scaled result. Keep the originalcompiledaround if you need to re-scale later (e.g. a live slider) — never re-scale an already-scaled result.