docs: sync core-parser spec and add unit normalisation
Bring core-parser.md in line with the resolved design in CONTEXT.md and ADRs 0004/0005, and promote a new decision to normalise ingredient/cookware units. - Full sync of the core-parser spec: diagnostics/total-function channel, container blocks (ListItemBlock, BlockquoteBlock), RawInline alongside RawBlock via position-slicing, the unified WikilinkNode/TransclusionNode shape as two typed nodes, and the minimal remark plugin set. - New ADR 0006: normalise ingredient/cookware units via a known-alias table with passthrough, case-insensitive, canonical-only; align Timer matching to case-insensitive too. - SPEC.md: units are normalised (new Units section, alias table); Timer table marked case-insensitive. - CONTEXT.md: add Unit Normalisation entry, resolve the open item. - fixtures/basic.md: add a blockquote (callout) carrying an annotation and a non-canonical unit so the integration test exercises containers and normalisation. - Expand the core test.todo checklists to cover the new behaviour (containers, raw fallbacks, diagnostics, unit normalisation, wikilink/transclusion variants); still pending, suite stays green.
This commit is contained in:
82
.claude/adr/0006-ingredient-cookware-unit-normalisation.md
Normal file
82
.claude/adr/0006-ingredient-cookware-unit-normalisation.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user