Building in the Fog, Part 3: The $3,323 Chicken Sausage
A unit conversion bug that produced 167 packs of chicken sausage at $3,323. The same bug existed independently in three code paths. The fix was centralizing the calculation — and deciding that grams, not packs, are the universal language.
Part 3 of "Building in the Fog" — a series about building Ridgeline, a provisioning decision engine for Outward Bound Hong Kong. Part 2 covered the scaling problem — 46 of 63 items didn't scale with group size because the spreadsheet was built for exactly 14 people.
167 packs
The workbench shows a cost column. One afternoon I'm looking at it and chicken sausage says $3,323.30.
For chicken sausage.
The provision list says the scenario needs 167 packs. The CP Chicken Frank variant is $19.90 per pack of 6 pieces, 340g. Multiply 167 by $19.90 and you get $3,323.30. The arithmetic is correct. The number of packs is not.
The meal item asks for 1,000g of chicken sausage. The variant sells in packs of 6 pieces. The calculator divides grams by pieces:
ceil(1000 / 6) = 167 packs
The correct calculation uses the variant's weight — 340g per pack:
ceil(1000 / 340) = 3 packs × $19.90 = $59.70
$59.70, not $3,323.30. Off by a factor of 56.
The Part Where "Pack" Means Three Different Things
The root cause predates the cost bug. When the spreadsheet was first digitized (01f95d7, Feb 16), ingredient quantities used whatever unit the spreadsheet used: "2 packs" of chicken sausage, "1 pack" of curry powder, "1 pack" of trail mix. The unit type was pack.
The problem is that pack is not a unit. It's a container. A pack of chicken sausage is 340g. A pack of trail mix is 150g. A pack of curry powder is 50g. The calculator can't convert between packs of different things because "pack" carries no information about magnitude.
Field staff need weight as the source of truth — they divide and chop items by weight. The purchasing system needs to convert weights into purchasable packs. These are two different operations, and "pack" collapses them into one ambiguous word.
The fix came in two stages on the same day the template was seeded.
Stage 1 (c3bad06, 20:40): Added purchasable-unit pack sizes to ~25 ingredients that only had content weights. If an ingredient only listed 500g as a pack size, the calculator couldn't find a matching pack when the meal item was in pack units. This was a patch — it made the numbers work without fixing the underlying ambiguity.
Stage 2 (8da3e37, 21:52): Eliminated pack as a unit type. A 309-line migration converted 31 ingredients from pack to g (weight-based, divisible) and 9 from pack to pcs (discrete countable items like energy bars). Seventeen meal-item quantities were rewritten from "N packs" to actual weight or piece values.
-- 'pack' is ambiguous — field staff need weight as source of truth
-- for dividing/chopping items. The calculator then figures out
-- how many purchasable packs to buy.
update public.ingredients
set unit_type = 'g',
pack_sizes = '[{"qty":500,"unit":"g"}]'
where name_en = 'Chicken Sausage';
After this migration, the chicken sausage meal item says 1000g instead of 2 packs. The ingredient is in grams. The question becomes: how many packs do I need to buy to get 1,000g?
The Bug in Three Places
That question was being answered in three independent locations — and all three had the same bug.
Location 1: calculator.ts — the provisioning engine. Computes cost per line item.
Location 2: scenarios/[id]/page.tsx — the workbench UI. Displays pack count in the table.
Location 3: csv.ts — the CSV export. Writes pack count to the downloadable file.
All three computed packs the same way:
// calculator.ts (before fix)
const packsNeeded = Math.ceil(packed / variantInfo.variant.pack_qty)
// workbench UI (before fix)
{Math.ceil(agg.total_packed_qty / agg.variant.pack_qty)}
// CSV export (before fix)
const packs = String(Math.ceil(agg.total_packed_qty / agg.variant.pack_qty))
Same formula. Same assumption: that the item's unit and the variant's pack_qty unit are the same. When chicken sausage is 1,000g and the variant pack is 6 pcs, you divide grams by pieces and get nonsense.
The bug survived because it only fires when units are mismatched — and before the pack-to-g migration, most items happened to share units with their variants. Once ingredients started using real units, the mismatches appeared.
Two Fixes, One Principle
Fix 1 (3aef6f9, Feb 25, 16:14): The unit conversion bridge. Instead of blindly dividing by pack_qty, the calculator now checks whether units are compatible. If they're not, it uses the variant's weight_g field to bridge:
const v = variantInfo.variant
const converted = convertPackToItemUnit(v.pack_qty, v.pack_unit, item.unit_type)
if (converted !== null && converted > 0) {
// Units compatible (same unit or kg↔g) — divide directly
packsNeeded = Math.ceil(packed / converted)
} else if (
(item.unit_type === 'g' || item.unit_type === 'kg') && v.weight_g > 0
) {
// Item is weight-based, variant is count-based — use weight_g
const packedG = item.unit_type === 'kg' ? packed * 1000 : packed
packsNeeded = Math.ceil(packedG / v.weight_g)
} else {
// No conversion possible — fall back to simple division
packsNeeded = Math.ceil(packed / v.pack_qty)
}
The test captures the exact chicken sausage scenario:
it('uses weight_g for cost when item unit is g but variant pack_unit is pcs', () => {
// item: 1000g chicken sausage
// variant: 6 pcs per pack, 340g, $19.90
// correct: ceil(1000/340) = 3 packs × $19.90 = $59.70
expect(item.line_cost).toBeCloseTo(59.7, 2)
})
Fix 2 (2b158de, Feb 25, 16:22): Centralization. Added packs_needed as a field on ProvisionItem and AggregatedItem. The calculator computes it once. The workbench and CSV read the computed value instead of recalculating:
// Workbench — after
{agg.packs_needed != null ? agg.packs_needed : '—'}
// CSV — after
const packs = agg.packs_needed != null ? String(agg.packs_needed) : ''
Eight minutes between the two commits. The first fixed the math. The second eliminated the class of bug — three places computing the same thing can't drift if only one place computes it.
Grams as the Universal Language
The deeper insight isn't about chicken sausage. It's about what happens when you digitize a system that was designed for human interpretation.
The spreadsheet said "2 packs." Rocky knew that meant 680g. The supply officer knew which packs to buy. The software doesn't know any of that — it needs explicit units and explicit conversions between them. "Pack" worked in the spreadsheet because a human was always in the loop. In code, pack is a lie.
Grams became the universal language. Recipes specify weight. Variants carry weight_g. The calculator bridges between them. The only things that stay in pcs are items you'd never cut in half — an energy bar, a pork burger, a fruit.1
Next: Part 4 — 1,200 Calories a Day, in which correct scaling reveals that the original menu provides half the calories a teenager needs on an expedition.
Footnotes
-
The
packenum value was never removed from the database — dropping Postgres enum values is destructive and requires recreating the type. It remains as a historical artifact that nothing references. A tombstone in the schema. ↩