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 guardrails are the artifact under test. A forecast that is obviously simple makes it unambiguous that the waste reduction comes from the ordering logic, not from a clever predictor.
- A better forecaster is a drop-in replacement. Because the forecast is a single function returning a number, an exponential-smoothing model, a gradient-boosted model, or a full demand-sensing system can replace it without touching a single guardrail. The safety properties do not depend on the forecast being good.
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.
| # | Rule | What it enforces | Real 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:
- Rounding before capping. Case-pack and pallet rounding run first so the shelf-life cap and the MOQ check see realistic, buildable quantities rather than fractional cases. Capping a fractional number and then rounding it could push coverage back over the shelf-life limit the cap just enforced.
- Line-local before aggregate. The shelf-life cap (rule 3) and MOQ (rule 4) decide each line's honest quantity before the truck-cube (rule 5) and DC-capacity (rule 6) rules reason about vendor and DC totals. Those aggregate rules can only be fair if the per-line numbers they sum are already final.
- Truck-cube before DC-capacity. Consolidating light vendor POs first changes the total inbound cube that the DC-capacity rule then measures, so rule 5 must settle before rule 6 looks at the day's total.
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
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:
| Tab | What 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. |