Skip to content

feat(log): --serving-amount + --serving-unit flags for unit overrides#26

Merged
phitoduck merged 15 commits into
mainfrom
feat/serving-unit-flags
Jun 11, 2026
Merged

feat(log): --serving-amount + --serving-unit flags for unit overrides#26
phitoduck merged 15 commits into
mainfrom
feat/serving-unit-flags

Conversation

@phitoduck

Copy link
Copy Markdown
Owner

Summary

Adds the --serving-amount <N> + --serving-unit <unit> flag pair on
lose-it log, letting the CLI mirror the official UI's
display-unit-override behavior for cup/mL/fl_oz/tbsp/g foods. The
existing --grams N flag is preserved as a legacy alias that internally
rewrites to --serving-amount N --serving-unit g, so there is exactly
one code path through _build_log_payload.

Spec: docs/serving-unit-spec.md
(merged separately via PR #23).

Wire-byte change (FoodServingSize block)

For lose-it log "...soup" --serving-amount 490 --serving-unit mL on a
cup-measured food (native ord = 3):

Before_build_log_payload with servings=2.07 (legacy --servings):

|27|2.07|1|28|3|1|1|2.07|
     ^      ^^   ^ ^
     f0     |ord |f4=1
            |    f3
            native ord (cup)

After_build_log_payload with override params:

|27|2.0711088904878836|1|28|11|1|236.5882365|490|
     ^                   ^^    ^ ^           ^
     canonical_servings  |     | conversion  user input
                         |     f3=1.0        in chosen unit
                         chosen ord (mL)

The override path matches the spec's wire-evidence snippet
27|2.071...|1|28|11|1.0|236.588|490| byte-for-byte modulo
float-precision serialization (the universal cup→mL constant is
236.5882365 — the captured UI value 236.58800000000002 is a
3-decimal-place rounded copy of the same constant; the spec explicitly
tolerates the precision difference).

CLI surface

Invocation Behavior
--serving-amount 490 --serving-unit mL New override path
--serving-amount 490 (no unit) exit 2, "must be passed together"
--serving-unit mL (no amount) exit 2, "must be passed together"
--serving-amount 0 --serving-unit mL exit 2, "must be positive"
--serving-amount 490 --serving-unit oz exit 2, "ambiguous"
--serving-amount 490 --serving-unit mL --servings 2.0 exit 2, "mutually exclusive"
--serving-amount 490 --serving-unit mL --grams 100 exit 2, "mutually exclusive"
--grams 86 (legacy) rewritten to serving_amount=86, serving_unit=g; same wire payload

Tests added

  • tests/conformance/test_units.py (14 tests): resolve_unit dispatch
    (case-insensitivity, oz-ambiguity rejection, unknown-unit rejection),
    conversion_factor lookups (cup→mL ≈ 236.588, cup→g returns None,
    mL→cup reciprocal, diagonal entries = 1.0, grams special case = 100.0).
  • tests/conformance/test_entries_serving_unit.py (8 tests):
    byte-compares the FoodServingSize block against the spec's wire
    evidence; regression-guards the per-serving FoodNutrients HashMap
    (no double-scaling); legacy-path regression guard for the existing
    --servings / --grams behavior.

Total: 93 tests pass, 5 skipped, 0 failed.

Deferred / out of scope

  • Phase 3 of the spec — suggested alternatives on unit mismatch.
    The CLI currently exits with a clear unit_not_supported error;
    proactively probing the rest of the search results for an entry whose
    native unit supports the requested one is left as a follow-up
    (marked with # TODO(serving-unit-spec.md Phase 3) in cli.py).
    Rationale: the parallel --food-id work removes most of the need
    (the user can pin the right entry directly), so the suggestion table
    is no longer urgent.

Composition with the parallel food-id-flow PR

This PR adds only the unit-override flow; the companion --food-id
flow (PR #TBD, branch feat/food-id-flow) adds stable food references.
The two compose cleanly:

lose-it log --food-id <hex> --serving-amount 490 --serving-unit mL --meal snacks

Both PRs touch cli.py and entries.py, but in disjoint regions (this
PR adds new option params to log's signature; the food-id PR adds
--food-id next to query). A merge conflict is possible at the
import block; a 3-way merge will resolve it trivially.

Test plan

  • uv run pytest --no-cov — all 93 tests pass, 5 skipped (live-only).
  • uv run prek run --all-files — clean.
  • tests/conformance/test_units.py added per spec Phase 1 case table.
  • tests/conformance/test_entries_serving_unit.py added with
    byte-compare against the spec's wire-evidence snippet.
  • Constructed-fixture wire-byte check confirms the FoodServingSize
    block emits |27|2.0711088904878836|1|28|11|1|236.5882365|490|.
  • Live mutating log (deferred until after PR merges; the
    constructed-fixture byte-compare is the load-bearing verification).

phitoduck added 15 commits June 11, 2026 15:58
Phase 1 of food-id-flow spec. Lowercase hex (32 chars) is the
user-facing alias for the 16-byte SimplePrimaryKey; ints in [-128, 127]
remain the SDK-internal 'response form'.
Phase 2 of food-id-flow spec. Wire shape matches the 2026-06-11
official-UI capture (UserId envelope + 16-byte SimplePrimaryKey, no
name/locale). Returns a FoodSearchResult so the caller can pipe it
straight into get_unsaved_food_log_entry.
Phase 3 of food-id-flow spec. The 32-char hex food_id appears
alongside the existing pk_bytes in JSON, and as a truncated 10-char
column in the text table. Lays the groundwork for log --food-id.
Phase 4 of food-id-flow spec. `lose-it log --food-id <hex>` skips
search + pick and goes straight through getFood -> unsaved-entry ->
log. The positional query becomes optional; the two paths are mutually
exclusive (validated up-front with exit 2). Dry-run + success lines
include a (id <prefix>...) tag.
Adds src/lose_it_utils/client/_units.py with CONVERSIONS, UNIT_ALIASES,
CANONICAL_UNIT_NAMES, resolve_unit(), conversion_factor() — the
unit-conversion table the official UI uses (236.5882365 mL/US cup, etc).
No string parsing; the CLI passes --serving-unit as a discrete value
and this module just looks it up.

Threads (measure_ord_override, quantity_in_chosen_unit, conversion_factor)
through entries._build_log_payload + entries.log_food. When set as a
triple, the FoodServingSize wire block emits the chosen-unit ord, the
universal conversion factor, and the user's raw input — matching the
official UI's outbound payload shape. Legacy callers (no override) are
unchanged.

Spec: docs/serving-unit-spec.md (Phase 1).
Phase 5 of food-id-flow spec. Documents the stable Food ID alongside
the existing --pick examples and adds a one-liner --food-id invocation
showing the drift-proof path.
After --grams is rewritten internally to --serving-amount/--serving-unit,
the rejection error flows through 'unit_not_supported' instead of the
legacy 'not_gram_measured' code. Adjust assertions accordingly.
- Upfront validation: both-or-neither, mutual exclusion with
  --servings and --grams, positive-amount check, resolve_unit
  for unit-name → ordinal.
- --grams N now rewrites to serving_amount=N, serving_unit=g
  so the override path is the single code path.
- After get_unsaved_food_log_entry, look up the conversion
  factor for (native_ord, chosen_ord). If unsupported, exit 2
  with a clear message (Phase 3 alternatives are TODO).
- Compute canonical_servings = serving_amount / factor and
  forward (measure_ord_override, quantity_in_chosen_unit,
  conversion_factor) to entries.log_food.
- Dry-run output now renders in the user's chosen unit.

Spec: docs/serving-unit-spec.md (Phase 2).
tests/conformance/test_units.py covers the Phase 1 case table from
docs/serving-unit-spec.md: resolve_unit dispatch (cup/mL/g/fl_oz with
case-insensitivity, oz-ambiguity rejection, unknown-unit rejection) and
conversion_factor lookups (cup→mL ≈ 236.588, cup→g returns None,
mL→cup reciprocal, diagonal entries = 1.0 except grams special case).

tests/conformance/test_entries_serving_unit.py byte-compares the
FoodServingSize block emitted by _build_log_payload against the spec's
captured wire snippet (27|<canonical>|1|28|11|1|236.5882365|490|), and
asserts the FoodNutrients HashMap is per-serving (no double-scaling
regression). Includes a legacy-path regression guard so the existing
--servings/--grams behaviour is unchanged.

Spec: docs/serving-unit-spec.md verification section.
…e --grams

Adds FoodMeasurement IntEnum + decoder hook so every decoded FoodMeasure
carries a plain-English 'unit' field. Surfaces in lose-it -o json output.
9 ordinals confirmed across >30 distinct foods: tbsp(2) cup(3) each(5)
grams(8) fl_oz(10) mL(11) slice(26) serving(27) scoop(33).

Parser now extracts FoodServingSize.f3/f4 — these encode the food's
stored per-serving qty in its native unit (e.g. Built Bar f4=40 g/serv,
TJ '8 fl oz' soup f4=8.0 fl_oz/serv, Core Power milkshake f4=414 mL/serv).
Math becomes: chosen_qty_per_serving = (f4/f3) × generic_cf(native→chosen),
then canonical_servings = user_amount / chosen_qty_per_serving.

This fixes a class of bugs where '1 serving = 100 g' or '1 fl_oz = 1
serving' were assumed everywhere. The legacy --grams flag is removed
(its math was off whenever a food's per-serving wasn't 100 g — e.g. Built
Bar, Orgain scoop, Real Good chicken strips per the user's catalog). Use
--serving-amount N --serving-unit g instead.
@phitoduck phitoduck merged commit 886cb5c into main Jun 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant