Perishable Replenishment A simple forecast, composed with hard ordering guardrails, that eliminates engineered spoilage without hiding the stockout trade-off

This is the inbound side of the distribution center, and the honest half of the suite. Load building and re-planning work with quantities that are already known. Replenishment does not: a purchase order commits to a vendor before the demand it covers is known. A forecast is genuinely required here. The demonstration is built so the forecast is the least interesting part, and the guardrails around it are the product.

What this demo proves

A deliberately blunt forecast, composed with six committed-purchase-order guardrails, beats a naive order-up-to policy on waste by a wide margin, and it does so without hiding the cost. At seed 42, across every scenario, the guarded plan projects 0 cases of waste while the naive policy spoils thousands of cases of perishables. The guarded plan pays for that with a modest, deliberate stockout risk on the shortest-shelf lines, and it shows that cost plainly rather than burying it. The claim is not "we forecast better." The claim is "composition turns an ordinary forecast into an order you can actually receive, sell, and afford."

The forecast, stated plainly

The forecast (forecast() in grocery_demos/core/replenishment.py) is a trailing 14-day mean, adjusted by a weekday factor. It averages the last fourteen days of unit sales, then scales that average by the forecast day's weekday factor relative to the weekly average. That is the entire model. Any grocer could compute it by hand.

This is a deliberate choice, not a limitation we are hiding:

The 90-day demand history the forecast reads (generate_demand_history() in grocery_demos/core/data_gen.py) is seeded and reproducible. Each store and SKU series contains weekly seasonality (a weekday profile that lifts on weekends), occasional promotions (a few days per horizon with a 1.8x to 2.5x uplift), and lognormal noise, with fresh SKUs made noisier than shelf stable ones. The trailing mean deliberately smears all of that together, which is exactly why the guardrails downstream have to catch what the forecast cannot see.

The six guardrails

The guardrails run in a fixed order on every line. The first four are line-local and live in ReplenishmentGuardrails.check_line(); the last two are aggregate (they need vendor totals and DC totals) and are applied by plan_orders() after all lines are computed. Every example below is a real adjustment from a seed 42, normal_week run, taken verbatim from the step trace the API returns.

#RuleWhat it enforcesReal seed-42 example (before → after)
1 case_pack_rounding Round the raw forecast requirement up to whole cases. You cannot buy a partial case. Ice Bag, Store 101: 32.57 → 33 cases
"195 units / 6 per case -> 33 case(s)"
2 pallet_tier_rounding Once an order reaches a full pallet layer (the tie count), round up to whole layers so the pallet builds cleanly. Ice Bag, Store 101: 33 → 40 cases
"33 cases >= 8/layer -> 40 cases (5 full layers)"
3 shelf_life_coverage Cap coverage at 75 percent of shelf life. Never buy more days of supply than the product can live to sell. This is the FEFO anti-spoilage rule. Eggs Dozen (4-day shelf), Store 101: 30 → 11 cases
"cap at 75% of 4d shelf life -> 11 cases"
4 vendor_moq Raise the line to the vendor minimum order quantity, or defer the line entirely when meeting the minimum would overshoot shelf life. Frozen Peas, Store 101: 9 → 10 cases
"raise 9 -> MOQ 10 cases"
5 truck_cube_min A vendor purchase order should fill at least 85 percent of one trailer's cube. If it does not, defer the non-urgent lines on that vendor to consolidate into a fuller truck later. Urgent lines are kept. Orange Juice, Store 101: 10 → 0 cases (deferred)
"vendor 9 PO 2194 ft3 < 2508 ft3 (85% truck); defer non-urgent line"
6 dc_receiving_capacity Total inbound cube per day stays under 60 percent of fleet cube. Overflow rolls to the next day, urgent lines kept first. Did not bind at seed 42 (0 lines changed in every scenario); inbound volume stays under the receiving ceiling. The rule is still evaluated and reported so its zero count is auditable.

Rules 1 and 2 push quantities up (toward buildable, shippable units). Rules 3 through 6 push them down or defer them (toward what will actually sell, ship full, and fit through the door). That tension is the whole point: the forecast says "here is demand," and the guardrails answer "here is what you can responsibly commit to."

The waterfall, and why order matters

Each line carries the ordered list of steps it passed through, and the Order Proposal tab renders that as a waterfall: the raw case count entering each rule, the count leaving it, and a plain-language detail sentence. The Ice Bag line above reads as a single story: 32.57 → 33 (whole cases) → 40 (whole pallet layers), and it survives the shelf-life cap because a 127-day frozen shelf life easily covers 40 cases.

Order of application is load-bearing, not cosmetic:

Because every step records its before and after, the final quantity is never a bare number. It is a traceable sequence of named decisions, which is what makes an order defensible to a buyer, a vendor, or an auditor.

Scenarios and real KPIs

The core forecast and guardrails do not carry a scenario knob; the demo blueprint (grocery_demos/web/replenishment_bp.py) shapes the seeded history into three scenarios by lifting only the trailing window the forecast reads, so every effect is an honest consequence of higher recent sales. normal_week is the unshaped baseline. promo_week lifts the last three days on about a third of lines. heat_wave boosts water, ice cream, frozen novelties, and fresh produce across the trailing window. All figures below are real outputs at seed 42, over 1,813 store-SKU lines.

Scenario Guarded waste Naive waste Stockout-risk lines (guarded) Deferred lines Cases ordered (guarded / naive)
normal_week 0 cases 3,179 cases 137 177 17,664 / 20,089
promo_week 0 cases 3,485 cases 139 156 19,841 / 22,589
heat_wave 0 cases 3,969 cases 138 156 21,064 / 24,596

The naive policy's projected waste climbs as demand heats up (3,179 to 3,485 to 3,969 cases) because a bigger trailing mean tells order-up-to to buy more, and more perishables bought means more spoilage. The guarded plan holds waste at zero in every case, because the shelf-life cap refuses to let coverage exceed what the product can outlive. The naive policy records zero stockout-risk lines in all three scenarios, which sounds ideal until you read the next section.

The honest trade-off

Both sides, plainly. The naive order-up-to policy reaches zero stockout-risk lines only by overstocking perishables into spoilage. At seed 42, normal_week, it orders 20,089 cases to the guarded plan's 17,664 (about 2,425 more cases) and spoils 3,179 of them. The guarded plan carries 0 projected waste cases and accepts 137 short-shelf stockout-risk lines as the deliberate price of not filling backrooms with product that expires before it sells. Neither outcome is free. Order-up-to trades money and thrown-away food for perfect availability; the guarded plan trades a small, visible availability risk on the shortest-shelf items for zero engineered waste. The demo shows you both numbers side by side and lets you decide, rather than reporting only the flattering one.

A "stockout-risk line" is a line whose final quantity, on top of on-hand stock, may not cover forecast demand through the vendor lead time before the next replenishment cycle. It is a risk flag, not a guaranteed empty shelf; most of these are short-shelf items where buying enough to be safe would mean buying more than can sell in time. That is precisely the tension perishable buyers manage by hand every day, made explicit and auditable here.

Reading the UI

The live demo at /grocery/replenishment has five tabs:

TabWhat it shows
Scenario Pick normal_week, promo_week, or heat_wave, set the seed, and run. A summary strip reports line count, guarded waste, stockout-risk lines, deferred lines, and naive waste for the run.
Forecast Line charts of a representative handful of store-SKU histories (a fresh short-shelf item, a promo line, a stable ambient item, plus frozen and refrigerated for variety). The shaded band is the trailing 14-day window the forecast averages; the highlighted dot is the resulting forecast point; promo days are marked. The label states the method explicitly.
Order Proposal Every line, raw cases through to final cases, filterable by store, temperature class, or status. Click any line to expand its guardrail waterfall with each rule's before, after, and detail sentence.
Guardrails The six rules explained in order, each with a live count of how many lines it actually changed in the current run (for example, at seed 42 normal_week: case_pack_rounding 1,813, pallet_tier_rounding 823, shelf_life_coverage 535, vendor_moq 551, truck_cube_min 51, dc_receiving_capacity 0).
Waste vs Stockout Guarded and naive KPIs side by side, with the honest framing above: zero engineered waste and a stated stockout risk versus zero stockouts bought with thousands of cases of spoilage.
See also: the live Replenishment demo, the Grocery documentation overview, and the sibling deep dives for Load Building and Disruption Re-Planning.