← Back to Blog

Building in the Fog, Part 2: The Scaling Problem

March 9, 2026

Changing the group size from 14 to 20 changed almost nothing in the output. 46 of 63 items were tagged 'per_group' because the spreadsheet was designed for exactly one group size. The fix required a question, not an algorithm.

Part 2 of "Building in the Fog" — a series about building Ridgeline, a provisioning decision engine for Outward Bound Hong Kong. Part 1 covered the site visit, the spreadsheet, and why the hardest part isn't building — it's understanding what to build.

The corporate retreat

During one of our recordings in the supply room office, Rocky mentions that groups aren't always 14 teenagers. Corporate retreats can be larger — 20, 30 people. Different programs run different sizes.

This is the kind of thing that sounds like a feature request but is a landmine. The spreadsheet was built for 14. Every quantity in every cell assumes 14. Rocky knows this. The supply officer knows this too. The spreadsheet works because the person using it already knows what to adjust.

The software doesn't have that luxury.

The Part Where Nothing Changes

When I digitized the spreadsheet into Ridgeline, the quantities went in as they appeared: 1.5kg spaghetti, 2 onions, 1 pack curry powder, 2 packs chicken sausage. The database schema had a scaling field on each meal item with two options — per_person or per_group.

The obvious items got tagged per_person: pork burgers (14 pcs — clearly 1 per person), udon noodles (14 pcs), instant rice (14 pcs). Everything with a quantity of 14 and a note that said "1 per person."

Everything else got per_group. The default.

The seed migration for Day 1 dinner (01f95d7):

insert into public.menu_template_meal_items
  (meal_id, ingredient_id, quantity, unit_type, scaling, notes)
values
  (m_d1, ...'Spaghetti',        1.5, 'kg',   'per_group', null),
  (m_d1, ...'Onion',            2,   'pcs',  'per_group', null),
  (m_d1, ...'Chicken Sausage',  2,   'pack', 'per_group', null),
  (m_d1, ...'Pork Burger',      14,  'pcs',  'per_person', '1 per person'),
  (m_d1, ...'Cheese Powder',    1,   'pack', 'per_group', null),
  (m_d1, ...'Tomato Paste',     1,   'can',  'per_group', null);

The scaling formula was straightforward:

if (scaling === 'per_person') {
  return qty * (groupSize / baseGroupSize) * portionFactor
}
// per_group: unchanged
return qty

For a group of 20 with baseGroupSize of 14, per_person items scale by 20/14 = 1.43x. per_group items don't move.

The problem: only 17 of 63 meal-item rows were per_person. The other 46 — spaghetti, rice, chicken sausage, fish balls, canned tuna, all the vegetables, all the snacks — stayed fixed no matter what number you typed in. Change the group from 14 to 100, and the provision list barely flinches.

This wasn't a bug in the code. The code did what the data told it to do. The data was faithfully transcribed from the spreadsheet. The spreadsheet was correct — for 14 people.

One Question

The fix wasn't an algorithm. It was a question:

"If I double the number of people, do I need more of this?"

Spaghetti? Yes — 1.5kg for 14 is about 107g per person. Double the people, double the pasta. Per person.

Curry powder? No — one pack seasons the pot whether you're feeding 10 or 20. Per group.

Onions? This is where it gets interesting.

Three Passes in Nineteen Minutes

The reclassification happened in three commits across nineteen minutes on February 25th. Each pass revealed items I'd gotten wrong in the previous pass.

Pass 1 (5c7f942, 23:31): The obvious ones. Spaghetti, rice, bread, muesli. Chicken sausage, fish ball, crab stick, ham, tuna, luncheon meat. Soup powders that feed everyone a bowl. Trail mix, biscuits, glucose candies. Twenty items converted from per_group to per_person.

Pass 2 (b63d3b4, 23:47): The canned fillings. Corn, baked beans, kidney beans, chickpeas, canned mushroom — these go into tortilla wraps and salads. One can split across 14 people is already thin. For 28 it's inadequate. Also milk powder (everyone gets milk with their muesli), seaweed soup, dried fruit. Eight more items.

-- 1 can split across 14 is already thin; for 28 it would be inadequate.
update public.menu_template_meal_items
set scaling = 'per_person'
where ingredient_id = (
  select id from public.ingredients where name_en = 'Baked Beans'
) and scaling = 'per_group';

Pass 3 (1019275, 23:50): The vegetables. I'd left onion, carrot, potato, cabbage, zucchini, and green pepper as per_group because they go into the cooking pot. But they're not aromatics — they're the actual vegetable content of the meal. 2 onions for 14 people is already not much. For 28 you'd need more.

The migration comment captures the reasoning:

-- These aren't just aromatics — they're the actual vegetable content of meals.
-- 2 onions or 4 potatoes for 14 people is already not much.
-- For 28 people you'd definitely need more.

Seven more items. Thirty-five total reclassified across three passes.

What's Left in the Pot

After the reclassification, only five items remained per_group: cheese powder, curry powder, tomato paste, salad dressing, lemon cordial. True "one unit seasons the pot" items.

But even this is a simplification. The ADR acknowledges what I started calling the "pot threshold" problem:

One pot of curry feeds ~14 people. At 20 you still get by with one pot. At 30 you need two pots, doubling all the cooking-base vegetables and seasonings.

Curry powder isn't linear (per person) or flat (per group). It's a step function — per pot. The jump happens at pot capacity, which depends on equipment that varies by program. Linear scaling would over-buy. Flat scaling under-buys at large groups. For OBHK's typical range of 8–20 people, flat is close enough. The safety margin system and pack-size rounding absorb the error.

A future per_pot scaling type could handle this more precisely. For now, the imprecision is small and understood — which makes it acceptable in a way that the original "nothing scales" was not.

The Missing Weights

The spreadsheet listed quantities — 1.5kg spaghetti, 2 packs chicken sausage, 1 can mushroom — but not the weight per pack or per piece for many items. To make per_person scaling produce sensible results, I needed to know what "2 packs of chicken sausage" weighs so the system could compute per-person grams and round to pack sizes.

The supply officer would know this from years of handling the physical products. He was on holiday.

So I scraped it. On the same day as the scaling fixes, I wrote scrapers for the suppliers' websites — Hart Limited (nuts and dried goods, 201 products with prices in HKD), Vastland (biscuits and bread, pack sizes but no prices), Garden (brochure site, minimal data). The Hart scraper alone produced a 1,012-line SQL migration seeding 35 ingredients and 147 variants with weights and prices (874552a).

This is the pattern of building where requirements are unclear. The spreadsheet gives you quantities without weights. The supply officer who knows the weights is unavailable. The supplier websites have the weights but in unstructured product pages. You build a scraper, fill in the blanks, and move on — knowing the data is approximate but better than nothing.

What Scaling Revealed

The reclassification fixed the immediate problem. Changing a group from 14 to 8 now reduces food items by ~43%. A portion factor of 1.3x increases items by 30% instead of having no effect. The provision list responds to inputs.

But it also surfaced something I didn't expect. With quantities now correctly attributed per person, the math showed that the original 14-person menu provides roughly 1,200–1,600 kcal per person per day. That's about half of what a teenager on a multi-day hiking expedition needs. That revelation leads somewhere — but first, there's a chicken sausage problem.

Next: Part 3 — The $3,323 Chicken Sausage, in which a unit conversion bug produces 167 packs of chicken sausage, and the same bug turns up in three different code paths.