Supply Planning Engine — Complete Technical Reference¶
What this page covers
End-to-end description of every phase the supply engine executes, every input it consumes, every constraint it enforces, and every output it produces. Audience: developers, data engineers, and supply planners who need to understand why the engine generates a particular order or projection.
Architecture Overview¶
The supply engine is a Python orchestrator wrapping a Rust core. The Rust
binary (supply_engine, compiled with PyO3) does all numerically intensive
work in parallel using Rayon. The Python layer handles data loading, pre- and
post-processing, and multi-echelon orchestration.
supply_runner.py (Python)
│
├─ Pre-processing (Python)
│ ├─ Netting pre-step forecast_netting.compute_and_persist()
│ ├─ Data loading _load_sku_plans()
│ ├─ Supersession handling _apply_supersession()
│ ├─ BFS upstream discovery _add_upstream_network_nodes() (max 4 BFS passes)
│ ├─ BOM explosion _explode_bom_demand()
│ ├─ Calendar pull-forward _apply_calendar_pull_forward()
│ └─ Demand aggregation _aggregate_demands_to_upstream_whs()
│
├─ PASS 1 Rust engine — upstream WH / hub nodes
│ supply_engine.run_supply_plan(upstream_plans, …)
│
├─ ALLOC _allocate_transfers() (Python, Kahn top-down proportional)
│
├─ PASS 2 Rust engine — store / leaf nodes
│ supply_engine.run_supply_plan(store_plans, …)
│
└─ Post-processing (Python)
├─ Lot-sizing snap _snap_lot_sizes()
├─ FEFO expiry simulation _simulate_fefo_expiry()
├─ Expedite order gen _generate_expedite_orders()
├─ Pre-position tagging _tag_prepos_orders()
└─ Projection enrichment _enrich_projections()
Rust engine internal passes (engine.rs::run_impl):
0. Preprocessing preprocess.rs forecast consumption + repair-return autofill
1. SKU filter engine.rs active SKU selection
2. Netting netting.rs requirements calculation (parallel)
3. Sourcing sourcing.rs route selection + lot-sizing (parallel)
4. Capacity scheduling.rs resource scheduling (parallel)
5. Re-netting netting.rs final stock projection with placed orders
6. Push push.rs expedite / split residual shortages
7. Pegging pegging.rs FIFO demand-to-supply links
8. Cost cost.rs total cost computation
Inputs — All Data the Engine Consumes¶
Per-SKU inputs (SkuPlan)¶
| Field | Type | Source | Description |
|---|---|---|---|
sku_id |
u32 | master.item.id |
Item primary key |
location_id |
u32 | master.location.id |
Site primary key |
initial_inventory |
f32 | master.on_hand |
Serviceable on-hand: total_on_hand − oh_bad_qty |
initial_oh_bad |
f32 | master.on_hand WHERE type = bad |
Unserviceable / failed units on-hand |
initial_oh_new |
f32 | master.on_hand WHERE type = new |
New stock (depleted only after good stock exhausted) |
expiry_period_weeks |
Option |
master.item.shelf_life_weeks |
Shelf life in weeks; NULL = immortal |
expiry_batches |
Vec | master.on_hand (batch-level) |
Known batches with explicit expiry dates |
demand[52] |
[f32;52] | PIPE_forecast_netting.supply_plan_qty |
Netted weekly demand (post-consumption, is_past=0 only) |
scheduled_receipts[52] |
[f32;52] | master.supply_orders_firm |
Firm/released/in-transit ERP orders |
repair_returns[52] |
[f32;52] | master.interact_sales_returns OR autofill |
Confirmed returns or autofilled from return_rate |
safety_stock[52] |
[f32;52] | PIPE_meio_results.committed_buffer |
MEIO safety stock (scalar expanded to 52 weeks) |
failure_rate[52] |
[f32;52] | master.route.return_rate |
Fraction of demand that returns (scalar expanded) |
firm_orders[52] |
[f32;52] | master.demand_actuals (confirmed only) |
Firm customer orders for Rust-side forecast consumption |
bad_returns[52] |
[f32;52] | master.interact_sales_returns |
Unserviceable units returning from field per week |
latest_firm_by_type[7] |
[u8;7] | master.supply_orders_firm |
Latest firm order week per order type (255=none) |
min_lt_by_type[7] |
[u8;7] | master.route |
Minimum lead time per order type (0=no route) |
is_upstream |
bool | BFS flag | True for WH/hub nodes; False for demand-leaf stores |
active |
bool | engine config | Whether to plan this SKU at all |
policy |
enum | supply scenario | Continuous / MinMax{min, target} / Poq{period_weeks} |
routes |
Vec\<RouteOption> | master.route |
All replenishment options (see below) |
Per-route inputs (RouteOption)¶
| Field | Type | Description |
|---|---|---|
route_id |
u64 | Route primary key |
source_type |
enum | Repair / Return / Balancing / Transfer / Build / Buy |
source_location_id |
Option\<u32> | Source site for Transfer/Balancing routes |
lead_time |
u8 | Weeks from release to nominal arrival |
lead_time_cv |
f32 | Coefficient of variation for stochastic lead-time padding |
unit_cost |
f32 | Cost per unit ordered |
order_cost |
f32 | Fixed cost per order placed |
min_qty |
f32 | Minimum order quantity (MOQ). Overridden by EOQ from linked inventory_scenario |
mult_qty |
f32 | Lot multiple — order must be a multiple of this value |
max_qty |
f32 | Maximum order quantity (0 = unlimited) |
capacity_id |
u32 | Links to CapacityResource (0 = unconstrained) |
shipping_calendar |
u64 | 52-bit bitmask of open shipping weeks |
receiving_calendar |
u64 | 52-bit bitmask of open receiving weeks |
repair_yield |
f32 | Fraction of returned units that can be successfully repaired |
return_rate |
f32 | Fraction of demand expected to return for repair |
repair_turnaround |
u8 | Total lag in weeks: return_transit + repair_TAT |
priority |
i16 | Route priority within same source type (lower = higher priority) |
quota |
f32 | Multi-sourcing fraction (0–1): this route covers quota × need |
setup_cost |
f32 | One-time changeover cost |
expedite_cost |
f32 | Premium multiplier on unit_cost when expediting |
transport_mode |
String | Air / Sea / Road / Rail |
is_expedite |
bool | If True, generates BUY_EXPEDITE orders in post-processing |
Global engine configuration (EngineConfig)¶
| Parameter | Default | Description |
|---|---|---|
horizon_weeks |
52 | Planning horizon in weeks |
workers |
0 (auto) | Rayon thread pool size (0 = CPU count) |
sparse_threshold |
0.0 | Skip SKUs with total demand below this |
source_priority |
[Repair,Return,Transfer,Build,Buy] | Order of preference when multiple routes can cover a shortage |
firm_weeks |
0 | Weeks in frozen horizon — no new engine orders generated |
z_score |
0.0 | Stochastic lead-time padding factor (0 = deterministic) |
consolidation_window |
0 | Merge consecutive orders within N weeks (0 = disabled) |
forward_fence |
4 | Forecast consumption window ahead of a firm order (weeks) |
backward_fence |
0 | Forecast consumption window behind a firm order (weeks) |
planning_mode |
ServiceOptimized | ServiceOptimized (fill rate first) or CostOptimized (minimize total cost) |
pegging_mode |
Full | Full / Partial / Off |
allocation_strategy |
Fifo | Fifo / Priority / FairShare / ExpiryFirst |
shortage_penalty |
50.0 | Penalty cost per unit of unmet demand |
lateness_penalty_per_week |
10.0 | Penalty per unit per week late |
lost_sales_penalty |
200.0 | Penalty per permanently lost sale |
holding_cost_pct |
0.25 | Annual holding cost as fraction of item value |
expedite_cost_multiplier |
1.5 | Extra cost factor for expedited orders |
overtime_cost_multiplier |
1.5 | Extra cost factor for capacity overtime orders |
soft_capacity_multiplier |
1.2 | Soft cap = hard cap × this factor |
nervousness_limit |
0.0 | Max plan-to-plan change rate (0 = disabled) |
service_level_weight |
0.7 | Weight in CostOptimized scoring |
cost_weight |
0.3 | Weight in CostOptimized scoring |
lateness_weight |
0.1 | Weight in CostOptimized scoring |
balancing_threshold |
0.1 | Minimum excess fraction to trigger cross-location balancing |
balancing_max_pct |
0.5 | Maximum fraction of excess to redistribute |
firm_orders_block_horizon |
false | Block new orders before the latest firm order per type |
respect_lead_time |
false | Block orders in the minimum-LT window (physically impossible) |
Inventory Types¶
The engine tracks five distinct inventory pools simultaneously:
| Pool | Field | Description | Used for |
|---|---|---|---|
| Good (serviceable) | initial_inventory = total_oh − oh_bad |
Units available for customer demand | Primary netting; depleted before new stock |
| Bad (unserviceable) | initial_oh_bad |
Failed / returned units awaiting repair | Repair-pool planning; does not satisfy demand |
| New stock | initial_oh_new |
New units (allocated only after good stock exhausted) | Projected separately; informational |
| Repair pipeline | repair_returns[52] |
Units arriving back from repair each week | Added to serviceable supply in netting pass |
| Scheduled receipts (ERP) | scheduled_receipts[52] |
Firm orders already in transit | Added to available supply; engine cannot modify these |
Additionally, expiry batches (expiry_batches) track lot-level shelf lives for FEFO simulation (see Phase 10).
Phase 0 — Python Pre-processing¶
0a. Context resolution¶
From config_scenario_pipeline, the runner resolves four scenario IDs:
supply_scenario_id— which supply parameter set to usemeio_scenario_id→ safety stock fromPIPE_meio_results.committed_bufferforecast_scenario_id→ demand fromPIPE_forecast_netting.supply_plan_qtyinventory_scenario_id→ EOQ/lot-sizing fromscen_inventory_scenario.results
0b. Netting pre-step¶
forecast_netting.compute_and_persist() refreshes PIPE_forecast_netting with
firm-order consumption applied. This is skipped if netting data was refreshed
within the last 60 minutes (SUPPLY_NETTING_MAX_AGE_MIN).
The netting pre-step combines statistical, maintenance, and causal forecasts,
applies forward/backward consumption against confirmed firm customer orders,
and writes supply_plan_qty — the final net demand the Rust engine will see.
0c. Data loading — _load_sku_plans()¶
A single massively denormalized SQL CTE loads all per-SKU data in one round-trip:
WITH
fcst AS (CH PIPE_forecast_netting: supply_plan_qty × 52 weeks per (item_id, site_id)),
eoq AS (scen_inventory_scenario.results → items[].eoq — overrides route.min_qty),
oh AS (master.on_hand: total_on_hand, oh_bad_qty, oh_new_qty, expiry_batches),
ss AS (CH PIPE_meio_results.committed_buffer per item×site),
primary_route AS (lowest-priority BUY/MAKE route per item×site),
ret_rt AS (RETURN routes: return_rate, return_lt_weeks),
rep_rt AS (REPAIR routes: repair_yield, repair_tat_weeks),
rts AS (json_agg ALL active routes per item×site),
sched AS (master.supply_orders_firm: scheduled_receipts[52])
SELECT
initial_inventory = total_on_hand - oh_bad_qty,
initial_oh_bad,
initial_oh_new,
safety_stock = COALESCE(committed_buffer, 0),
min_qty = CASE WHEN eoq valid THEN floor(eoq/mult)*mult
ELSE route.min_qty END,
routes_json,
demand[52],
scheduled_receipts[52]
EOQ integration: when the pipeline is linked to an inventory_scenario, the
per-SKU EOQ replaces route.min_qty as the floor on lot sizing. The value is
first aligned to the mult_qty boundary: min_qty = floor(eoq / mult) × mult.
0d. Supersession handling¶
# Global supersession (no site_id): blocks ALL BUY/MAKE routes for the old item_id
# Site-specific: blocks exact (item_id, site_id) pairs
# stock_rollable: on-hand of from_item added to to_item.initial_inventory at same site
Items marked as superseded have their BUY/MAKE routes silenced — the engine
will not generate procurement orders for them. Stock of a superseded item is
rolled forward to its successor item's starting inventory when
stock_rollable = true.
0e. BFS upstream node discovery — _add_upstream_network_nodes()¶
for echelon in range(max_echelons): # default: 4
# Find all source_site_id values from TRANSFER/MAKE routes
# for items already in the plan
new_nodes = query("""
SELECT DISTINCT r.item_id, r.source_site_id
FROM master.route r
JOIN master.route_type rt ON rt.id = r.type_id
WHERE rt.planning_type IN ('TRANSFER', 'MAKE')
AND r.item_id IN (:known_ids)
""")
if not new_nodes: break
# Add each new (item, site) with demand=[0]*52, is_upstream=True
Each BFS pass discovers one upstream echelon. Default depth limit = 4 BFS passes → maximum 5 levels (4 upstream WHs + 1 store leaf). See the depth limit discussion below.
Upstream nodes are initialized with:
- demand = [0.0] × 52 (no direct customer demand)
- initial_inventory from master.on_hand at that site
- scheduled_receipts from ERP firm orders at that site
- is_upstream = True
0f. BOM explosion — _explode_bom_demand()¶
For items with BUILD routes, child component demands are augmented:
The explosion is applied bottom-up through the BOM tree so multi-level assemblies are handled correctly.
0g. Calendar pull-forward — _apply_calendar_pull_forward()¶
For each route with a ship_calendar_id, the Python layer computes closure
weeks from the 52-bit bitmask and accumulates any demand falling in those
weeks into the last available week before the closure. These pre-position
orders are tagged TRANSFER_PREPOS in post-processing.
0h. Demand aggregation — _aggregate_demands_to_upstream_whs()¶
Kahn topological sort (leaves → root): each upstream WH's demand[] is set
to the sum of all downstream nodes it supplies. This ensures Pass 1 sees the
correct aggregate demand for BUY order generation at warehouses that have zero
direct customer demand.
Phase 1 — Rust Engine: Upstream Nodes (Pass 1)¶
A single call to supply_engine.run_supply_plan(upstream_plans, …) with all
upstream (WH/hub) nodes. The Rust engine runs all internal passes
(preprocessing → netting → sourcing → scheduling → push → pegging → cost) on
these nodes simultaneously.
TRANSFER orders are never generated in Pass 1 — the transfer_pool is
always [0;52], meaning the engine treats TRANSFER as unavailable for
upstream nodes. BUY, BUILD, REPAIR, and RETURN orders are generated normally.
Output: BUY/BUILD/REPAIR/RETURN orders + 52-week inventory projections for every upstream node.
Phase 2 — Transfer Allocation — _allocate_transfers()¶
Kahn topological sort root → leaves. This is the inverse direction of demand aggregation.
Algorithm¶
For each WH node in root-first topological order:
1. Compute available supply this week:
ROOT WH (has BUY arrivals):
flow[w] = max(0, prev_inv + buy_arrivals[w] - curr_inv)
(implied demand flow — avoids double-counting on-hand)
INTERMEDIATE WH (pass-through, no BUY routes):
supply = fresh TRANSFER arrivals from its parent only
2. Compute each downstream node's need:
need[dest][w] = demand[w] + max(0, ss[w] - current_projected_inv[dest])
3. Distribute proportionally:
alloc[dest][w] = available[w] × need[dest][w] / Σ need[all_dests][w]
4. Record TRANSFER order and update dest's scheduled_receipts for Pass 2
For a 5-level chain this produces a cascade:
Step 1: Root WH → Hub (root BUY arrivals pushed down)
Step 2: Hub → Country WH (TRANSFER arrivals from root pushed down)
Step 3: Country WH → Region (TRANSFER arrivals from hub pushed down)
Step 4: Region → Store (TRANSFER arrivals fed into store scheduled_receipts)
SS-aware allocation¶
ss_part = max(0, alloc - demand[w]) # safety stock top-up portion
# Deducted from WH's own SS budget to avoid over-committing buffers
Phase 3 — Rust Engine: Stores (Pass 2)¶
A single call to supply_engine.run_supply_plan(store_plans_p2, …) with all
store (leaf) nodes. Each store now has TRANSFER arrivals injected into its
scheduled_receipts from the allocation step.
The engine runs the same internal passes as Pass 1. Stores whose TRANSFER supply is insufficient may generate local BUY orders (fallback procurement). Residual shortages appear as exceptions.
Rust Engine Internal Passes¶
Figure: end-to-end flow of one run_supply_plan call — demand ingest, netting, sourcing, capacity scheduling, push, pegging, cost, then order emission.
sequenceDiagram
participant P as "Python orchestrator"
participant E as "Rust engine"
participant S as "Sourcing"
participant C as "Capacity scheduler"
participant PG as "Pegging"
P->>E: "run_supply_plan(SkuPlans)"
E->>E: "Pass 0 preprocess (forecast consumption + repair autofill)"
E->>E: "Pass 1 active SKU filter"
E->>E: "Pass 2 netting (shortage per week)"
E->>S: "Pass 3 sourcing (route priority, lot-size)"
S-->>E: "planned orders (qty, release, arrival)"
E->>C: "Pass 4 capacity scheduling"
C-->>E: "Planned / Delayed / Overtime / Constrained"
E->>E: "Pass 5 re-netting + projections"
E->>E: "Pass 6 push (expedite / split)"
E->>PG: "Pass 7 FIFO pegging"
PG-->>E: "PegLink[] (demand, supply)"
E->>E: "Pass 8 cost computation"
E-->>P: "orders + projections + pegging"
Rust Pass 0 — Preprocessing (parallel via Rayon)¶
Forecast consumption (apply_forecast_consumption):
For each firm_orders[fw] > 0:
remaining = firm_orders[fw]
backward pass: reduce demand[fw-backward_fence .. fw-1] (default: 0)
forward pass: reduce demand[fw .. fw+forward_fence] (default: 4 weeks)
Always clamp to ≥ 0
This is the Rust-side (second) forecast consumption. The Python-side netting
pre-step already applied firm-order consumption to PIPE_forecast_netting; the
Rust pass re-applies it on the raw firm_orders[] array for any firm orders
loaded from ERP that were not yet reflected in the netting output.
Repair-return autofill (autofill_repair_returns):
If repair_returns is all-zero AND the SKU has a Repair route:
For each Repair route (return_rate, repair_yield, repair_turnaround):
repair_returns[w] = demand[w - repair_turnaround]
× return_rate × repair_yield
Take MAX over all Repair routes per week
This generates a statistical repair-return forecast when no confirmed returns exist, ensuring the engine accounts for the expected repair pipeline even without explicit return records.
Rust Pass 1 — Active SKU filter¶
SKUs with zero demand throughout the horizon are skipped unless they have safety stock requirements.
Rust Pass 2 — Netting (parallel via Rayon, netting.rs)¶
The netting pass computes net requirements week by week:
carry = initial_inventory
For t in 0..52:
available = carry + scheduled_receipts[t] + repair_returns[t]
required = demand[t] + safety_stock[t]
if available + NETTING_EPS ≥ required:
carry = (available - demand[t]).max(0.0) # satisfy demand; buffer intact
shortage[t] = 0.0
else:
shortage[t] = (required - available).max(0.0) # how much we're short
carry = (available - demand[t]).max(0.0) # physical stock stays positive
# KEY: when only the SS buffer is breached, carry can still be > 0
# e.g. available=20, demand=10, SS=25: shortage=5, carry=10 (not 0)
inventory[t] = carry
NETTING_EPS = 1e-4 (prevents f32 ULP drift from producing false shortages)
Safety-stock-aware carry: when available inventory covers demand but not the safety stock buffer, the physical carry-forward remains positive. Only when physical stock would go negative does the carry reach zero. This prevents cascading phantom shortages from propagating through subsequent weeks.
Rust Pass 3 — Sourcing (parallel via Rayon, sourcing.rs)¶
For each shortage week, routes are tried in the configured source_priority
order (default: Repair → Return → Transfer → Build → Buy).
Pre-sourcing checks (per route, per shortage week):
| Check | Condition | Effect |
|---|---|---|
| Frozen horizon | firm_orders_block_horizon=true AND week < latest_firm_by_type |
Skip this route type this week |
| Min lead time | respect_lead_time=true AND week < min_lt_by_type |
Skip — physically impossible |
| Repair pool | source_type=Repair | Skip if repair_pool[week] == 0 |
| Transfer pool | source_type=Transfer/Balancing | Skip if transfer_pool[week] == 0 |
Lead-time calculation with stochastic padding:
effective_LT = if lead_time_cv > 0 && z_score > 0 {
lead_time + ceil(z_score × lead_time_cv × lead_time)
} else {
lead_time
};
Calendar adjustment:
raw_release = max(0, need_week - effective_LT)
release_week = shipping_calendar.shift_to_open(raw_release, Backward)
raw_arrival = release_week + effective_LT
arrival_week = receiving_calendar.shift_to_open(raw_arrival.min(51), Forward)
A 52-bit bitmask calendar operation: shift_to_open(week, Backward) finds the
latest open week at or before week; Forward finds the earliest open week
at or after week.
Lot-sizing — allocate() — 7-step algorithm:
Input: needed (shortage quantity), route (min_qty, mult_qty, max_qty, quota)
Step 1: quota → quota_needed = needed × route.quota (if 0 < quota < 1)
Step 2: max cap → capped = min(quota_needed, max_qty) (0 = unlimited, skip)
Step 3: min floor→ with_min = max(capped, min_qty)
Step 4: min>max → if with_min > max_qty: return 0 (infeasible)
Step 5: mult up → rounded = ceil(with_min / mult_qty) × mult_qty
Step 6: max cap → after_max = min(rounded, max_qty)
Step 7: floor → if mult_qty > 0 AND max_qty > 0:
floored = floor(after_max / mult_qty) × mult_qty
if floored < min_qty: return 0 (infeasible — e.g. min=10, mult=6, max=11)
return floored
else: return after_max
Order-splitting when max_qty is binding:
while remaining > 0 && orders.len() < 10_000 {
let qty = allocate(remaining, route);
if qty == 0 { break; }
orders.push(order(qty, release_week, arrival_week));
remaining -= qty;
}
Multi-sourcing (quota routes): each quota route is solved independently
against the same remaining, allocating its proportional share.
Order policies:
| Policy | Trigger condition | Order quantity |
|---|---|---|
Continuous (default) |
Any shortage | Exactly the shortage amount (then lot-sized) |
MinMax{min_qty, target_qty} |
Any shortage | max(target_qty + ss_gap, shortage) |
Poq{period_weeks} |
Only in weeks that are multiples of period_weeks |
Aggregated demand for the POQ window |
Optional: Consolidation pass¶
When consolidation_window > 0, consecutive orders for the same
(sku, location, route) within N weeks are merged:
# Sort orders by (sku_id, location_id, route_id, release_week)
# Greedy merge: if gap ≤ consolidation_window:
# sum qty, keep earlier release_week, keep later arrival_week
Consolidation reduces the number of purchase orders at the cost of slightly higher cycle stock.
Rust Pass 4 — Capacity scheduling (parallel, scheduling.rs)¶
Figure: how an order interacts with a capacity resource — soft-cap overtime band, week-by-week scan, and the Constrained fallback.
flowchart TD
A["Order placed by sourcing"] --> B{"capacity_id > 0?"}
B -->|"no, unconstrained"| C["Status = Planned"]
B -->|"yes"| D["Enter scheduling loop<br/>check_week = original_release"]
D --> E{"avail >= needed?"}
E -->|"yes"| F["Schedule at check_week"]
E -->|"no"| G{"avail x (1 + soft_mult) >= needed?"}
G -->|"yes"| H["Schedule with overtime<br/>Status = Overtime"]
G -->|"no"| I{"check_week < 52?"}
I -->|"yes"| D
I -->|"no"| J["Status = Constrained<br/>CapacityExceeded exception"]
F --> K{"check_week > original_release?"}
K -->|"yes"| L["Status = Delayed"]
K -->|"no"| C
Orders linked to a capacity resource (capacity_id > 0) go through the
capacity scheduler.
Priority ordering (highest priority scheduled first):
| Source type | Priority |
|---|---|
| Repair | 5 (highest) |
| Return | 4 |
| Transfer / Balancing | 3 |
| Build | 2 |
| Buy | 1 (lowest) |
Within the same source type, lower unit_cost is preferred.
Scheduling loop:
for check_week in original_release_week..=51:
avail = capacity_map.get((cap_id, check_week)).unwrap_or(0.0);
if avail >= needed:
schedule at check_week → status = Planned (or Delayed if > original_release_week)
capacity_map[(cap_id, check_week)] -= needed
break
else if soft_capacity_multiplier > 0
&& avail × (1 + soft_capacity_multiplier) >= needed:
schedule at check_week → status = Overtime
break
// else: try next week
if not scheduled:
status = Constrained
generate CapacityExceeded exception (severity = Critical)
Rust Pass 5 — Re-netting + bad/new stock projection¶
After placing all orders, a final netting pass is run with the new order
arrivals added to scheduled_receipts:
extra_receipts[arrival_week] += order.qty // for each placed order
(final_inv, final_shortage) = netting_pass(plan, extra_receipts)
Bad-stock projection (informational, does not affect netting):
bad_carry = initial_oh_bad
for w in 0..52:
oh_bad[w] = bad_carry
bad_carry = max(0, bad_carry + bad_returns[w] - repair_returns[w])
New-stock projection (good stock depletes before new stock):
oh_good_init = max(0, initial_inventory - initial_oh_new)
for w in 0..52:
oh_new[w] = new_carry
consumed = max(0, prev_total - final_inv[w])
from_good = min(consumed, good_carry); good_carry -= from_good
new_carry = max(0, new_carry - (consumed - from_good))
prev_total = final_inv[w]
Rust Pass 6 — Push pass: expedite and split (push.rs)¶
For each week that still has remaining shortage after capacity scheduling:
Strategy 1 — Expedite:
// Find the nearest Buy/Repair order arriving AFTER need_week for this SKU
// Pull it back by 1 week:
order.release_week -= 1
order.arrival_week = max(need_week, order.arrival_week - 1)
order.status = Expedited
Strategy 2 — Split:
// Find an order with qty > 2 × remaining_shortage
// arriving within 2 weeks after need_week
// Split off remaining_shortage quantity → new order at need_week (status=Expedited)
// Reduce original order qty
Strategy 3 — UnmetDemand exception (severity = Critical): No expedite or split possible — the shortage cannot be covered.
Rust Pass 7 — FIFO pegging (pegging.rs)¶
Figure: FIFO pegging tree — earliest demand is satisfied from initial inventory and earliest-arriving supply first; residual demand becomes unmet.
graph TD
D1["Demand week 12<br/>qty = 10"]
D1 --> S0["Initial inventory<br/>arrival week 0, qty = 4"]
D1 --> S1["Firm order PO-7<br/>arrival week 9, qty = 3"]
D1 --> S2["Planned BUY<br/>arrival week 11, qty = 3"]
D2["Demand week 18<br/>qty = 6"]
D2 --> S2r["Planned BUY (remainder)"]
D2 --> S3["Planned BUY<br/>arrival week 16, qty = 6"]
D2 -.->|"queue exhausted"| U["Unmet demand<br/>(no PegLink)"]
Oldest-arriving supply is matched to earliest demand.
// Build supply queue sorted by arrival_week ASC
// Include initial_inventory as virtual supply at week 0 (order_id = 0)
for t in 0..52 where demand[t] > 0:
remaining = demand[t]
// Peg from initial_carry first, then from supply_queue FIFO
while remaining > 0 && !supply_queue.is_empty():
supply = supply_queue.peek_mut()
if supply.arrival_week > t: break // can't peg future supply to past demand
pegged = min(remaining, supply.qty)
PegLink { order_id, demand_week: t, supply_week: supply.arrival_week, qty: pegged }
remaining -= pegged
supply.qty -= pegged
if supply.qty ≤ 0: supply_queue.pop()
// Remaining demand after queue exhausted = unmet (no PegLink)
Rust Pass 8 — Cost computation¶
for order in final_orders:
base = order.qty × order.unit_cost + order.order_cost
extra = match order.status:
Expedited → order.qty × order.unit_cost × (expedite_cost_multiplier - 1.0)
Overtime → base × (overtime_cost_multiplier - 1.0)
_ → 0.0
order.total_cost = base + extra + order.penalty_cost
The full CostBreakdown struct (cost.rs) decomposes cost into five
components:
| Component | Description |
|---|---|
production_cost |
Unit procurement / manufacturing cost |
transport_cost |
Standard freight cost (unit cost × qty for BUY routes) |
inventory_cost |
Holding cost accumulated over any delay period |
delay_penalty |
Service penalty, AOG cost, contractual lateness fees |
capacity_flex_cost |
Overtime premium, overflow storage, premium freight |
Phase 4 — Python Post-processing¶
Lot-sizing snap¶
After the Rust engine returns orders, Python re-applies min/mult/max constraints to correct any floating-point drift:
snapped = min(raw_qty, max_qty)
snapped = max(snapped, min_qty)
snapped = ceil(snapped / mult_qty) * mult_qty
snapped = min(snapped, max_qty)
snapped = floor(snapped / mult_qty) * mult_qty
if snapped < min_qty:
snapped = 0.0 # infeasible — drop the order
FEFO expiry simulation¶
For items with expiry_period_weeks or explicit expiry_batches:
# Seed the FEFO queue from on-hand batches (explicit expiry dates)
# plus remaining OH (assumed to expire in expiry_period_weeks)
# Add arriving supply orders to queue at arrival_week + expiry_period_weeks
for w in 0..52:
# Remove expired batches (expiry_week ≤ w)
expired[w] = sum of expired batch quantities
# Sort queue by expiry_week (FEFO)
# Consume demand from earliest-expiry first
# Record expired_qty[w]
# Attach expiry_date and expired_qty to each supply order
Expedite order generation¶
For routes with is_expedite = True, the post-processor generates
BUY_EXPEDITE orders to cover any remaining shortage weeks not resolved by
the engine's push pass.
Pre-position tagging¶
Any TRANSFER order matching a pre-position anchor recorded during
_apply_calendar_pull_forward() is relabelled TRANSFER_PREPOS.
Projection enrichment¶
Projection rows from Rust contain only inventory[] and shortage[]. Python
adds:
- demand[] (from plan data)
- supply_received[] (sum of arriving order quantities per week)
- safety_stock[] (from plan data)
- expired_qty[] (from FEFO simulation)
All Order Types¶
| Type | DB value | Description |
|---|---|---|
Buy |
BUY |
External procurement from supplier |
Build |
BUILD |
Internal manufacturing / assembly |
Repair |
REPAIR |
Refurbishment of returned unserviceable units |
Transfer |
TRANSFER |
Cross-site replenishment (Python allocation) |
Balancing |
BALANCING |
Cross-location redistribution of excess stock |
Return |
RETURN |
Anticipated customer returns (as supply) |
BUY_EXPEDITE |
BUY_EXPEDITE |
Expedited BUY via premium route (post-processing) |
TRANSFER_PREPOS |
TRANSFER_PREPOS |
Pre-position transfer ahead of calendar closure |
Order statuses¶
Figure: planned-order lifecycle — sourcing places Planned, capacity scheduler may Delay / Overtime / Constrain, push pass may Expedite.
stateDiagram-v2
[*] --> Planned: "Sourcing places order"
Planned --> Delayed: "Capacity scheduler pushes to later week"
Planned --> Overtime: "Scheduled in soft-capacity band"
Planned --> Constrained: "No capacity slot in horizon"
Planned --> Expedited: "Push pass pulls order in early"
Delayed --> Expedited: "Push pass expedite"
Overtime --> Expedited: "Push pass expedite"
Constrained --> [*]: "CapacityExceeded exception"
Expedited --> [*]: "Final order row"
Planned --> [*]: "Final order row"
| Status | Badge | Meaning |
|---|---|---|
Planned |
Green | Normal planned order; no constraint triggered |
Delayed |
Amber | Pushed to a later week by the capacity scheduler |
Overtime |
Orange | Scheduled using the soft-capacity (overtime) band |
Expedited |
Blue | Pulled in early by the push pass to cover a shortage |
Constrained |
Red | No capacity slot found in the entire horizon |
All Exception Types¶
| Type | Severity | Trigger |
|---|---|---|
Shortage |
Warning | Safety stock buffer violated (not physical stockout) |
UnmetDemand |
Critical | Physical demand cannot be covered — push pass exhausted |
CapacityExceeded |
Critical | No capacity slot found for an order across the full horizon |
LateOrder |
Warning | Order arrives after its demand week |
RepairPoolEmpty |
Warning | No repair inventory available when a Repair route is needed |
BalancingPoolEmpty |
Info | No excess stock available for cross-location balancing |
LostSale |
Critical | Firm demand where min LT > demand date + tolerance (Python-generated) |
Multi-Echelon Architecture¶
BFS depth limit¶
The only depth limit is the BFS cap in _add_upstream_network_nodes():
| BFS iteration | Echelon discovered (example) |
|---|---|
| 1 | Direct source of stores → country WH |
| 2 | Source of country WH → regional hub |
| 3 | Source of regional hub → central WH |
| 4 | Source of central WH → root supplier |
With the default max_echelons = 4, up to 5 total levels are supported
(4 upstream + 1 store leaf). To support deeper networks, raise the cap:
Everything downstream of BFS discovery (demand explosion, Pass 1, allocation, Pass 2) is depth-agnostic — it uses Kahn topological sort which works on any DAG of any depth.
Comparison: MRP engine vs LP solver¶
| Aspect | MRP engine | LP solver |
|---|---|---|
| Upstream planning | Pass 1: all upstream nodes, greedy priority | Unified LP: all nodes, cost-minimizing |
| Transfer quantities | Proportional push allocation | LP decision variables — globally optimal |
| Store planning | Pass 2: stores see injected TRANSFER arrivals | Same LP — all echelons unified |
| Intermediate WH rebalancing | Not possible — one-pass top-down | LP can rebalance across echelons |
| Depth sensitivity | Proportional error compounds per echelon | LP is exact regardless of depth |
| Performance | Very fast (Rust parallel, milliseconds per 1000 SKUs) | Slower (LP solver scales with variables²) |
Key Design Decisions¶
Why the safety stock does not consume physical stock¶
In the netting pass, shortage[t] is set when available < demand[t] + ss[t]
but carry is computed as max(0, available - demand[t]). This means:
- A safety-stock violation produces a shortage signal without reducing physical inventory to zero.
- This prevents a single safety-stock event from cascading into phantom shortages in all subsequent weeks.
- Physical stockouts (when
available < demand[t]) are reported separately viaUnmetDemandexceptions.
Why TRANSFER orders are never generated by the Rust engine¶
The Rust transfer_pool is always [0;52]. This is a deliberate architectural
choice: the Python _allocate_transfers() function owns all inter-location
movement logic, using topological-sort cascading that the Rust engine cannot
replicate. Pass 1 generates supply at WHs; Pass 2 gives stores their TRANSFER
arrivals; the Rust engine sees them as indistinguishable from any other
scheduled receipt.
Why scheduled receipts from the ERP are immutable¶
master.supply_orders_firm is the only source for scheduled_receipts[].
Engine-generated orders from prior runs are never re-loaded as scheduled
receipts. This prevents the plan from building on its own outputs and makes
each supply run fully reproducible from master data alone.
Repair route auto-sourcing¶
When a Repair route exists but repair_returns[] is all zero (no confirmed
returns yet), the engine autofills a statistical repair forecast:
This ensures the engine accounts for the expected repair pipeline from day one of going live, before any actual return records exist.
Database Outputs¶
ClickHouse (analytics layer, partitioned by pipeline_id + scenario_id)¶
| Table | Content |
|---|---|
PIPE_supply_orders |
All planned orders with qty, release_week, arrival_week, unit_cost, total_cost, status, order_type |
PIPE_supply_inventory_projection |
52-week stock projections: inventory, shortage, demand, supply_received, safety_stock, oh_bad, oh_new, expired_qty |
PIPE_supply_pegging |
Demand ↔ supply peg links (PegLink): order_id, demand_week, supply_week, qty |
PIPE_supply_exceptions |
All exceptions: type, severity, week, qty, message |
All tables use archive_date partitioning. Queries filter to the latest
version via MAX(archive_date) using the _latest_supply_version() helper.
Module Reference¶
| Module | Location | Role |
|---|---|---|
| Python orchestrator | files/supply_runner.py |
Data loading, pre/post-processing, 2-pass coordination |
| Rust PyO3 binding | files/supply/src/lib.rs |
Entry points: run_supply_plan, run_supply_plan_msgpack, run_allocation |
| Engine orchestrator | files/supply/src/engine.rs |
Coordinates all Rust passes; run_impl() |
| Data types | files/supply/src/types.rs |
SkuPlan, RouteOption, SupplyOrder, InventoryProjection, EngineConfig |
| Preprocessing | files/supply/src/preprocess.rs |
Forecast consumption + repair-return autofill |
| Netting | files/supply/src/netting.rs |
Net requirements calculation (Passes 2 + 5) |
| Sourcing | files/supply/src/sourcing.rs |
Route selection + lot-sizing allocate() |
| Capacity scheduling | files/supply/src/scheduling.rs |
Resource scheduling, soft/hard caps |
| Push pass | files/supply/src/push.rs |
Expedite and split logic |
| Pegging | files/supply/src/pegging.rs |
FIFO demand-to-supply links |
| Transfer allocation | files/supply/src/allocation.rs |
Kahn topo-sort cascading proportional push |
| Calendar | files/supply/src/calendar.rs |
52-bit bitmask operations |
| Cost | files/supply/src/cost.rs |
CostBreakdown struct |
| Constraints | files/supply/src/constraints.rs |
Hard/Soft constraint algebra, ViolationCostFn |
| Solver | files/supply/src/solver.rs |
LP/constrained alternative sourcing |
| Alternatives | files/supply/src/alternatives.rs |
Alternative sourcing option generation |
| Graph | files/supply/src/graph.rs |
Supply network graph utilities |
| Supply graph | files/supply/src/supply_graph.rs |
Extended graph for constrained planning |
| Explanation | files/supply/src/explanation.rs |
Human-readable decision reason generation |
| API router | files/api/supply_router.py |
FastAPI routes under /api/supply/ |
Cross-references: LP Transport Solver · Supply Planning (User Guide) · Advanced Supply Planning · EOQ & Lot-Sizing · MEIO Theory