When an ingredient has two quantities in units that cannot be combined, they are rendered in random order — different on every run, and different between output formats within the same run.
Reproduction
mkdir -p config
printf 'Mix @flour{1%%cup} with more @flour{100%%g}.\n' > bake.cook
printf '[baking]\nflour\n' > config/aisle.conf
for i in 1 2 3 4 5 6; do cook shopping-list bake.cook | grep flour; done
flour 1 cup, 100 g
flour 100 g, 1 cup
flour 1 cup, 100 g
flour 100 g, 1 cup
flour 1 cup, 100 g
flour 100 g, 1 cup
Cause
cooklang::quantity::GroupedQuantity stores unconvertible units in a HashMap<String, Quantity>, and Rust randomises HashMap iteration order per process. Every consumer that walks GroupedQuantity::iter() or into_vec() inherits that.
Why it appeared now
#433 removed the bundled_units feature so quantities keep their authored units. Previously there was a unit database, cup and g were known units, and they merged into a single quantity — so ordering never came up. With the feature off, both components survive and the randomised map becomes visible.
#433's own updated snapshots did not catch it because none of their fixtures has an ingredient with two inconvertible units.
Scope — wider than the shopping list
Each of these walked the map independently, so they could disagree with each other in a single run:
cook shopping-list — human table, Markdown, JSON and YAML
cook recipe — ingredient table and cookware amounts (human, Markdown, LaTeX, Typst, JSON-LD)
GET /api/recipes/... — grouped_ingredients[].quantities was in random order per request
- The web UI's ingredient lists
Anything diffing CookCLI output between runs, or comparing the API response to what the CLI prints, sees spurious differences.
Suggested direction
Sort the components at every render/serialise boundary. Ordering by unit name with the unitless component first is deterministic, platform-independent (byte-wise str comparison, no locale), and easy to state. Declaration order is not recoverable — the HashMap has already lost it.
Notes
Fixed in the cookcli-core extraction branch (#434), which routes all writers through one ordering helper and adds a 64-iteration regression test using units no converter knows, so it exercises the randomised map regardless of feature flags.
This is the fourth instance of HashMap iteration order reaching user-visible output in this codebase — see also #427.
When an ingredient has two quantities in units that cannot be combined, they are rendered in random order — different on every run, and different between output formats within the same run.
Reproduction
Cause
cooklang::quantity::GroupedQuantitystores unconvertible units in aHashMap<String, Quantity>, and Rust randomisesHashMapiteration order per process. Every consumer that walksGroupedQuantity::iter()orinto_vec()inherits that.Why it appeared now
#433 removed the
bundled_unitsfeature so quantities keep their authored units. Previously there was a unit database,cupandgwere known units, and they merged into a single quantity — so ordering never came up. With the feature off, both components survive and the randomised map becomes visible.#433's own updated snapshots did not catch it because none of their fixtures has an ingredient with two inconvertible units.
Scope — wider than the shopping list
Each of these walked the map independently, so they could disagree with each other in a single run:
cook shopping-list— human table, Markdown, JSON and YAMLcook recipe— ingredient table and cookware amounts (human, Markdown, LaTeX, Typst, JSON-LD)GET /api/recipes/...—grouped_ingredients[].quantitieswas in random order per requestAnything diffing CookCLI output between runs, or comparing the API response to what the CLI prints, sees spurious differences.
Suggested direction
Sort the components at every render/serialise boundary. Ordering by unit name with the unitless component first is deterministic, platform-independent (byte-wise
strcomparison, no locale), and easy to state. Declaration order is not recoverable — theHashMaphas already lost it.Notes
Fixed in the
cookcli-coreextraction branch (#434), which routes all writers through one ordering helper and adds a 64-iteration regression test using units no converter knows, so it exercises the randomised map regardless of feature flags.This is the fourth instance of
HashMapiteration order reaching user-visible output in this codebase — see also #427.