# ADR 0006 — Ingredient/Cookware Unit Normalisation **Status**: Accepted ## Context Timer annotations already normalise their unit to a canonical abbreviation from a fixed known set (`mins` → `min`, `hours` → `hr`). Ingredient and Cookware units, by contrast, were originally specified as free-form strings passed through verbatim, and unit normalisation for them was listed as out of scope in the core-parser spec. That left a gap. A consumer aggregating ingredients for a shopping list receives `200 g`, `200 grams`, and `200 G` as three distinct units, and has to re-implement normalisation itself — once per consumer (the CLI, the future Obsidian plugin). The domain glossary flagged this as an open question, leaning toward normalising known aliases to a canonical abbreviation but leaving the exact table and canonical forms undecided. Three approaches were considered: **Option A — Free-form passthrough (status quo)** Ingredient/cookware units stay verbatim strings; consumers normalise if they care. **Option B — Known-alias table with passthrough** The parser maintains a fixed alias→canonical table. A unit matching an alias is rewritten to its canonical abbreviation; any unit not in the table passes through unchanged. **Option C — Full unit system** Model units as typed quantities with dimensional analysis and conversion (mass/volume, metric/imperial), enabling arithmetic across units. ## Decision **Option B** — a known-alias table with passthrough — plus **case-insensitive** matching for all annotation unit tables (ingredient/cookware **and** timer). - A unit whose text matches a known alias is normalised to its canonical abbreviation. - Lookup is case-insensitive; matching is done on the trimmed unit remainder, and multi-word aliases (`fluid ounce`) match on the whole remainder. - The node stores only the canonical unit; the author's original spelling is not retained. - Any unit not in the table is passed through verbatim, with its original casing and spacing preserved. - Timer unit matching, previously unspecified on case, is made case-insensitive too, so both normalisers follow one casing rule. The canonical set and aliases: | Canonical | Aliases | |-----------|---------| | `g` | g, gram, grams | | `kg` | kg, kilogram, kilograms, kilo, kilos | | `mg` | mg, milligram, milligrams | | `oz` | oz, ounce, ounces | | `lb` | lb, lbs, pound, pounds | | `ml` | ml, milliliter, millilitre, milliliters, millilitres | | `l` | l, liter, litre, liters, litres | | `tsp` | tsp, teaspoon, teaspoons | | `tbsp` | tbsp, tablespoon, tablespoons | | `cup` | cup, cups | | `fl oz` | fl oz, fluid ounce, fluid ounces | | `pt` | pt, pint, pints | | `qt` | qt, quart, quarts | | `gal` | gal, gallon, gallons | Single-letter cooking abbreviations (`t`, `T`, `c`) are excluded as ambiguous — and they would collide under case-insensitive lookup. Count and descriptive units (`clove`, `pinch`, `dash`, `can`, `large`, `to taste`) have no canonical form and pass through unchanged. ## Rationale Option A pushes the same normalisation logic onto every consumer and guarantees they drift; the parser is the one place that sees every annotation and is the natural home for it, exactly as it already is for timers. Consistency with the existing Timer behaviour is the deciding factor — having timers normalise but ingredients not would be an arbitrary split. Option C is disproportionate. Recipe units are overwhelmingly written already-canonical or as a short list of aliases; dimensional analysis and cross-unit conversion solve a problem no consumer has asked for, and they force decisions (metric/imperial conversion factors, density for mass↔volume) that belong to a consumer feature, not the parser. Passthrough for unlisted units is what keeps Option B safe: culinary units are open-ended (`pinch`, `clove`, `to taste`), so any attempt to normalise algorithmically would mangle them. A fixed table normalises exactly the units with an unambiguous canonical form and leaves everything else untouched, so no annotation's unit is ever dropped or corrupted. Storing only the canonical unit (rather than also a raw form) matches Timer and keeps the node minimal. Annotation nodes are not part of the lossless round-trip guarantee — that is ADR 0005's job for unmodelled OFM, and an annotation's sigil and braces are consumed regardless — so there is no losslessness argument for retaining the raw unit. If a round-trip consumer ever needs the original spelling, adding a `rawUnit` field later is additive and non-breaking. Case-insensitive matching reflects how authors actually write (`2 Tbsp`, `200 ML`), and aligning Timer to the same rule avoids the footgun of `~5 Mins` silently falling through to plain text while `@flour{200 G}` normalises fine. ## Consequences - `IngredientNode` and `CookwareNode` carry a canonical `unit` when the written unit matches an alias, and the verbatim unit otherwise. - The parser owns a single unit-alias table shared in spirit with the Timer table; both use case-insensitive lookup. - Timer unit matching is now case-insensitive, a small change to the language spec's Timer section. - Ingredient/cookware unit normalisation moves from "out of scope" to specified parser behaviour in the core-parser spec. - The alias table can be extended additively; adding an alias or a new canonical unit is non-breaking. - A future unit-conversion or dimensional-analysis feature, if ever needed, is a consumer-level concern layered on top of these canonical units, not a parser change.