🥤BevFacts

Technical / Part 2

Part 2 — Calculation Engine

The deterministic pipeline that turns a base recipe plus an ordered list of modifiers into the as-ordered nutrient vector.

DBNF-1 · Part 2 Normative Core stages live; tables are reference defaults

The engine is a pure function: compute(base, modifiers) → computed. Same inputs, same outputs, on every conforming implementation — that determinism is what makes a printed label auditable after the fact. All arithmetic is performed on unrounded values; declaration rounding (Part 3) is applied exactly once, at the end.

2.1The nutrient vector

Every recipe, modifier delta, and result is a 12-component vector:

N = ⟨ calories, totalFat_g, satFat_g, transFat_g, cholesterol_mg, sodium_mg,
      totalCarb_g, fiber_g, sugars_g, addedSugars_g, protein_g, caffeine_mg ⟩

Vector addition and scalar multiplication are component-wise. A delta vector may contain negative components (e.g. a milk substitution that reduces fat); a result vector may not (§2.7).

2.2Modifier pipeline

Modifiers apply in this fixed order regardless of the order they were tapped on the order screen. The order is normative because the stages do not commute: sweetness is a multiplier over base sugars and must bind before absolute additions like pumps.

StageModifier typeKindEffectStatus
1sizescalarN ← N × (mL_ordered / mL_base), all componentsLive via per-size recipes
2ice_levelscalarliquid-volume correction, §2.3.2Draft
3milk_substitutiondeltaN ← N + (M_new − M_old) × v_milk, §2.4Draft
4sweetnesspartial scalarscales sugars, addedSugars only; energy corrected, §2.5Live
5syrup_pump, espresso_shotdelta × nabsolute additions, §2.6Live
6toppingdeltaabsolute addition of the topping vector, §2.6.3Draft

2.3Size & ice

2.3.1 Size scaling

Where the vendor publishes per-size recipes (recommended — syrup pumps rarely scale linearly with volume), the ordered size's recipe MUST be used and stage 1 is skipped. Where only one base size exists, linear scaling by volume ratio is the fallback:

k = mL_ordered / mL_base          // e.g. 591/473 = 1.249 for 16→20 fl oz
N ← N × k

Linear fallback MUST be flagged in the DBNF document as "sizeMethod": "linear_estimate" so auditors can distinguish measured recipes from projections.

2.3.2 Ice displacement

Ice displaces liquid; "light ice" means more drink in the same cup. For iced (not blended) drinks, the liquid fraction scales by the ice factor:

ice_levelFactor on liquid-derived nutrientsNotes
none× 1.30Cup fully liquid
light× 1.15
regular× 1.00Baseline; recipes are published at regular ice
extra× 0.85

These are reference defaults; vendors SHOULD replace them with measured values. Blended drinks are exempt (ice is an ingredient, already in the recipe). Absolute additions in stages 5–6 (a pump is a pump) are not ice-scaled — this is why ice binds at stage 2, before them.

2.4Milk substitution

A substitution replaces the recipe's default milk with another. The engine needs the drink's milk volume v_milk (mL) and the per-100mL vectors of both milks:

N ← N + (M_new − M_old) × (v_milk / 100)

2.4.1 Reference milk table (per 100 mL)

Milkkcalfat gsat gcarb gsugars gadded gprotein g
whole_dairy613.31.94.84.803.2
reduced_2pct502.01.24.94.903.3
nonfat340.10.15.05.003.4
oat_barista502.10.26.72.92.91.3
almond_unsw131.00.10.60.300.4
soy331.70.21.70.40.42.9
coconut_bev312.11.92.92.52.50.2

Reference defaults compiled from typical commercial products; vendors MUST override with their actual products' values at L2+ (a sweetened oat milk quietly reclassifies sugars as added sugars — exactly the kind of change DBNF exists to surface).

2.5Sweetness

Sweetness percentage — standard in boba ordering, implicit elsewhere — scales only the sugar components. 100% is the vendor's published recipe; the domain is s ∈ [0, 1.5] in steps the vendor chooses (25% steps in the reference implementation).

sugars'      = sugars × s
addedSugars' = addedSugars × s
totalCarb'   = totalCarb + (sugars' − sugars)          // carbs track their sugar fraction
calories'    = calories + 4 × (sugars' − sugars)        // 4 kcal per g (Atwater)

Sweetness MUST NOT scale naturally occurring sugars where the vendor can distinguish them (e.g. lactose in milk is not reduced by "50% sweet"). Where the split is unknown, scaling total sugars is the accepted approximation and MUST be flagged "sweetnessMethod": "total_sugars_approx".

2.6Absolute additions

2.6.1 Syrup pumps

sugars      += p × g_pump          // default g_pump = 5 g
addedSugars += p × g_pump
totalCarb   += p × g_pump
calories    += p × kcal_pump       // default 20 kcal (sugar 4 kcal/g × 5 g)

Vendors SHOULD define per-syrup vectors (a sugar-free syrup is ⟨0-sugar, ~0 kcal⟩ but may carry sodium; a mocha sauce carries fat). The default exists so an engine can always produce a conservative estimate.

2.6.2 Espresso shots

caffeine += e × 75 mg
calories += e × 5 kcal

75 mg is the reference default for a ~30 mL shot; measured values range 60–100 mg by roast and dose. Decaf shots contribute 3 mg.

2.6.3 Reference topping table (per standard serving)

Toppingkcalfat gcarb gsugars gadded gprotein g
boba_pearls (¼ cup cooked)15003828280
cheese_foam (40 g)180146553
whipped_cream (30 g)10094331
cold_foam (60 mL)351.53302
grass_jelly (¼ cup)40010880
egg_pudding (¼ cup)9021612122
caramel_drizzle (10 g)350.58770

2.7Invariants

After every stage — not only at the end — a conforming engine MUST enforce:

  1. addedSugars ≤ sugars ≤ totalCarb (clamp added sugars down, never sugars up);
  2. no component below zero (clamp to 0; emit warning clamped_negative);
  3. satFat + transFat ≤ totalFat;
  4. energy reconciliation: |calories − (4·carb + 4·protein + 9·fat)| ≤ max(10, 5% of calories) — violations emit energy_reconciliation_failed and SHOULD block L3 publication (they nearly always mean a recipe-entry error);
  5. domain checks: s ∈ [0, 1.5], p ∈ [0, 12], e ∈ [0, 8] — out-of-range input is a 422, not a silent clamp (Part 5 §9.1).

2.8Numeric precision

Engines MUST compute in IEEE-754 binary64 (or better) and carry full precision between stages — intermediate rounding is nonconforming because it makes results order-dependent. Comparisons in test vectors use tolerance ε = 10⁻⁶ relative. Where two conforming engines disagree after declaration rounding (Part 3), the declaration is still identical if both carried full precision — this is the point of the rule. Ties in declaration rounding use round-half-up (matching FDA practice), not banker's rounding.

2.9Worked example, end to end

Order: 16 fl oz caramel latte (base below), oat milk substitution (250 mL milk), 100% sweetness, +3 vanilla pumps (defaults), +2 shots.

base      = ⟨250 kcal, 7 fat, 4.5 sat, 0 trans, 25 chol, 170 Na,
             35 carb, 0 fiber, 33 sugars, 28 added, 10 protein, 150 caf⟩

stage 3   milk: (oat − 2%) per 100 mL = ⟨0 kcal, +0.1 fat, −1.0 sat, +1.8 carb,
             −2.0 sugars, +2.9 added, −2.0 protein⟩ × 2.5
          → ⟨250, 7.25, 2.0, 0, 25, 170, 39.5, 0, 28, 28*, 5, 150⟩
             *added clamped to ≤ sugars 28 (invariant 1: 28+7.25→ capped)

stage 4   sweetness s=1.0: no change

stage 5   pumps p=3: sugars 28→43, added 28→43, carb 39.5→54.5, kcal 250→310
          shots e=2: caffeine 150→300, kcal 310→320

computed  = ⟨320 kcal, 7.25 fat, 2.0 sat, 0 trans, 25 chol, 170 Na,
             54.5 carb, 0 fiber, 43 sugars, 43 added, 5 protein, 300 caf⟩

declared  (Part 3): Calories 320 · Total Fat 7g (9%) · Sat Fat 2g (10%) ·
          Carb 55g (20%) · Sugars 43g · Added 43g (86%) · Caffeine 300mg
warnings  = ["added_sugars_over_50pct_dv"]

2.10Test vectors

A conforming engine MUST reproduce these (unrounded, ε = 10⁻⁶). The live stages can be checked in the browser console on the app via BevFacts.compute(...).

#Input (abbrev.)Expected computed
T1{sugars:33, addedSugars:28, calories:250, caffeine:150, sweetness:100, pumps:3, shots:2}calories 320 · sugars 48 · added 43 · caffeine 300
T2{sugars:48, addedSugars:46, calories:420, sweetness:50}sugars 24 · added 23 · calories 324
T3{sugars:10, addedSugars:0, calories:100, sweetness:150, pumps:1}sugars 20 · added 5 · calories 140
T4{sugars:0, addedSugars:0, calories:5, caffeine:0, shots:4}calories 25 · caffeine 300
T5{sugars:33, addedSugars:40, calories:250, sweetness:100} (bad input: added > total)added clamped to 33 + warning, or 422 at API surface
T6{sugars:20, addedSugars:20, sweetness:0}sugars 0 · added 0 · calories −80 kcal from base, floor at 0