Skip to content

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.

  1. Global Scaling — a flat factor (--scale 2).
  2. Reverse Scaling — a target quantity for one ingredient (--scale flour=400g), from which the factor is derived.
  3. 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=1kg when the recipe is written in 500g computes a factor of 0.002 instead of 2 if 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

ts
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 — compiled isn'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.

  • convertUnit is optional and injected by the caller. @gram/kitchen doesn't know about density, ingredient databases, or the UNIT_CONVERSIONS table at all — those live entirely in @gram/analyzer (which needs them anyway for mass standardization). The engine only ever calls it as convertUnit(value, fromUnit, toUnit); without one, only exact unit matches (after alias normalization, e.g. "gram" → "g") succeed.

    @gram/analyzer's own convertUnit(value, fromUnit, toUnit, density?) takes an optional density (g/mL) as its 4th argument to bridge mass ↔ volume (e.g. flour=1L against a recipe in g) — without one, it only converts within the same family (mass↔mass, volume↔volume), same as the bare table. The caller resolves that density once, via resolveIngredientDensity(id, database, overrides) (checking a recipe's frontmatter densities: override before the ingredient database), and binds it into a closure before handing it to resolveScaleFactor — 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 codeWhy it's rejectedWhat the message suggests
INGREDIENT_NOT_FOUNDNo ingredient with that id in the shopping listA "did you mean" suggestion against the recipe's real ingredient ids
NESTED_ONLY_TARGETIngredient only exists inside a composite/sub-recipe's usage[], never at top levelScale the parent composite instead — its own total (e.g. "2 lemons") is itself a valid target
ALTERNATIVE_TARGETThe id is one option inside an alternative-ingredient groupPick a different, unambiguous ingredient — the error lists the sibling options
FIXED_INGREDIENTMarked @=, or a TextQuantity like "a pinch" (which is fixed by definition)Never scales, so can't describe a scale factor either
RELATIVE_TARGETQuantity is formula-derived (70% @&flour)Scale the target ingredient (flour) instead — see Relative Quantities
AMBIGUOUS_MULTI_UNITSame ingredient appears in two incompatible units across the recipeThe shopping list total can't be reduced to one number
UNIT_MISMATCHUnits belong to different physical families (mass vs. volume) and no density could be resolvedAdd a density via gram db enrich, declare one in the recipe's densities: frontmatter, or match units
INVALID_FACTORNon-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 portions value.
  • No shared-reference corruption. compile() itself defensively clones the AST's meta object 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

  1. Compile normally (compile(), optionally analyze() if you want mass/Baker's data).
  2. Build a ScaleRequest from user input (a raw number, or an id + quantity + unit).
  3. Call resolveScaleFactor(compiled, request, convertUnit?). Catch ScaleError and switch on .code for UI-specific messaging — every error already carries a complete, user-facing .message.
  4. Call applyScale(compiled, resolution.factor) to get the scaled result. Keep the original compiled around if you need to re-scale later (e.g. a live slider) — never re-scale an already-scaled result.