Technical / Part 3
Part 3 — Rounding & Daily Values
How computed values become declared values: the 21 CFR 101.9(c) rounding rules, the DV tables, and the exact strings that go on the label.
BevFacts.round.*
3.1Order of operations
The single most common labeling bug is rounding too early. The normative order is:
compute (Part 2, full precision)
├──▶ %DV = round( unrounded_value / DV × 100 ) // from UNROUNDED values
└──▶ declared = declaration_round( unrounded_value ) // §3.2, once
%DV is computed from unrounded values, then rounded to a whole percent. Computing %DV from the already-declared mass is nonconforming — it compounds two roundings and can shift the percentage by several points at beverage-typical magnitudes.
3.2Declaration rounding rules
Ties round half-up (2.5 g → 3 g at 1 g increments), matching FDA practice. The functions below are exported verbatim by the reference implementation as BevFacts.round.*.
| Quantity | Rule | Function |
|---|---|---|
| Calories | < 5 → 0 · ≤ 50 → nearest 5 · > 50 → nearest 10 | round.calories |
| Total / sat / trans fat (g) | < 0.5 → 0 · < 5 → nearest 0.5 · ≥ 5 → nearest 1 | round.fat |
| Cholesterol (mg) | < 2 → 0 · 2–5 → "less than 5" · > 5 → nearest 5 | round.cholesterol |
| Sodium (mg) | < 5 → 0 · 5–140 → nearest 5 · > 140 → nearest 10 | round.sodium |
| Carb / fiber / sugars / added / protein (g) | < 0.5 → 0 · < 1 → "less than 1" · ≥ 1 → nearest 1 | round.gram |
| % Daily Value | nearest whole percent (from unrounded mass, §3.1) | — |
| Caffeine (mg) | < 1 → 0 · 1–5 → "less than 5" · > 5 → nearest 5 | DBNF rule; no FDA equivalent exists |
less than 5 communicates "effectively decaf" honestly.3.3Daily values (2,000 kcal reference diet)
| Nutrient | DV | Shown on label? |
|---|---|---|
| Total fat | 78 g | %DV shown |
| Saturated fat | 20 g | %DV shown |
| Trans fat | — | mass only, never %DV |
| Cholesterol | 300 mg | %DV shown |
| Sodium | 2,300 mg | %DV shown |
| Total carbohydrate | 275 g | %DV shown |
| Dietary fiber | 28 g | %DV shown |
| Total sugars | — | mass only, never %DV |
| Added sugars | 50 g | %DV shown — the number DBNF exists for |
| Protein | 50 g | mass only on beverage labels |
| Caffeine | no DV; 400 mg/day FDA guidance | mass only; guidance %MAY appear in companion UI, never on-label |
3.4%DV computation
pctDV(nutrient) = round_half_up( unrounded(nutrient) / DV(nutrient) × 100 ) // whole %
Zero declarations still show 0% where the row carries %DV. Values over 100% print as-is (128%) — capping at 100% is nonconforming; the whole point is that one drink can exceed a day.
3.5Declared-string grammar
The exact strings, in ABNF. No locale-dependent formatting inside the value strings; unit is attached without a space, matching FDA convention.
declared-mass = ( "less than " unit-num / unit-num ) unit
unit-num = 1*4DIGIT [ "." DIGIT ] ; "4.5"
unit = "g" / "mg"
declared-cal = 1*4DIGIT ; no unit on the label
declared-pct = 1*3DIGIT "%"
added-row = "Includes " declared-mass " Added Sugars"
Examples: 4.5g · less than 1g · 170mg · Includes 43g Added Sugars · 86%.
3.6Warning thresholds
Warnings are machine-readable flags in the DBNF document (Part 4); companion UIs decide presentation. Thresholds evaluate against unrounded values.
| Flag | Condition | UI requirement |
|---|---|---|
added_sugars_over_50pct_dv | addedSugars ≥ 25 g | SHOULD surface a caution |
added_sugars_over_100pct_dv | addedSugars ≥ 50 g | MUST surface a caution |
caffeine_over_half_daily | caffeine ≥ 200 mg | SHOULD surface |
caffeine_over_daily | caffeine ≥ 400 mg | MUST surface; SHOULD require confirmation on kiosks |
clamped_negative, energy_reconciliation_failed | Part 2 §2.7 | engine diagnostics; MUST NOT be shown to consumers |
3.7Test vectors
Verifiable in the console via BevFacts.round.*:
| Call | Expected | Why |
|---|---|---|
round.calories(4.9) | 0 | < 5 → 0 |
round.calories(47.5) | 50 | ≤ 50, nearest 5, half-up |
round.calories(324) | 320 | > 50, nearest 10 |
round.fat(4.74) | "4.5" | < 5, nearest 0.5 |
round.fat(5.4) | "5" | ≥ 5, nearest 1 |
round.cholesterol(3) | "less than 5" | 2–5 band |
round.sodium(138) | "140" | 5–140, nearest 5 |
round.sodium(146) | "150" | > 140, nearest 10 |
round.gram(0.7) | "less than 1" | 0.5–1 band |
round.gram(0.4) | "0" | < 0.5 → 0 |