Supply Inventory Drift Above Safety Stock — Root-Cause & Fix¶
Date: 2026-06-28
Series investigated: 16_301 (item_id=16, site_id=301, pipeline_id=4)
Scope: Rust supply engine (supply/src/) + Python allocation (supply_runner.py)
Companion page: supply-inventory-drift-fix.html
TOC¶
- Symptom
- Investigation trail
- Root cause — double supply from repair autofill
- The red herring — allocation cover buffer
- The fix
- Before / after data
- Files changed
- Validation
- Risks & guards
- Out of scope
Symptom¶
On the series page for http://mirabelle.ddns.net/series/16_301, the projected
stock line climbs monotonically above the displayed safety-stock line and never
plateaus within the 52-week horizon.
| Metric (archive 2026-06-15, before fix) | Value |
|---|---|
Safety stock (MEIO committed_buffer) |
5.0 (constant, all 52 weeks) |
| Total demand over horizon | 40.47 |
| Total supply_received | 42.77 (+2.3 surplus) |
| Projected inventory week 0 → 51 | 5.09 → 7.83 (monotonic climb) |
| TRANSFER orders | 52 (one every week, qty 1–2, total 72) |
The weekly surplus of ~0.05 accumulates because supply_received > demand every
week, and demand is too low to pull the surplus back down.
Investigation trail¶
Step 1 — Netting logic (initial suspect)¶
The Rust netting_pass (supply/src/netting.rs:18) computes:
let available = carry + plan.scheduled_receipts[t] + plan.repair_returns[t] + extra_receipts[t];
let required = plan.demand[t] + plan.safety_stock[t];
When available >= required, carry = (available - demand).max(0.0) — it keeps
everything above demand, including any surplus above SS. So a weekly surplus
accumulates. This pointed at where does the surplus come from?
Step 2 — Allocation cover buffer (red herring)¶
The Rust allocate_transfers (supply/src/allocation.rs:347) had a hardcoded
forward cover look-ahead:
This sizes store deliveries to keep inventory ≥ SS for lead_time + 3 weeks forward,
driving the target level to SS + cover_weeks × avg_demand ≈ 9.25. This looked like
the cause — but the production path does not use Rust allocation (it is disabled
per a comment at supply_runner.py:653 in favour of the Python _allocate_transfers).
Step 3 — Python allocation tracker¶
Instrumenting the Python _allocate_transfers showed the allocation tracker
correctly held the store at SS=5.0:
16_301 transfer orders: 52 qty_sum: 43.24
16_301 init_inv: 2.7 demand_sum: 40.94 sr_sum: 43.24
16_301 projected peak (alloc tracker): 5.0 final: 5.0 SS=5
The Python allocation was not the source of the drift — the tracker was correct.
Step 4 — Pass-2 store MRP (the divergence)¶
The store's TRANSFER receipts (SRs) are injected as scheduled_receipts into the
Pass-2 Rust engine run. Capturing the plan sent to Pass 2:
sr_sum: 43.236 dem_sum: 40.936 → +2.3 surplus
sr[0:6]: [3.572, 1.005, 0.984, 1.059, 0.846, 1.188]
dem[0:6]: [1.272, 1.005, 0.984, 1.059, 0.846, 1.188]
Week 0: SR=3.572 vs demand=1.272 (+2.3, the SS top-up). Weeks 1–5: SR=demand exactly (net zero). Yet the Rust result showed inventory climbing 5.089 → 7.87.
Step 5 — f32 rounding hypothesis (false lead)¶
The SR values are f32-derived floats (e.g. 3.572036946715087). In f32,
2.7f32 + 3.572f32 = 6.272037 (overshoot by ~3.7e-5). But simulating the exact
f32 netting produced carry=5.0 exactly — no drift. f32 rounding was not the cause.
Step 6 — Repair returns autofill (the real cause)¶
The store has a Repair route:
The Rust preprocessing pass (supply/src/preprocess.rs:127, autofill_repair_returns)
computes repair returns when the plan's repair_returns array is all-zero:
// repair_returns[w] = demand[w - turnaround] * return_rate * repair_yield
let qty = plan.demand[src] * rr * ry; // = demand[w] * 1.0 * 0.07 = 7% of demand
This repair supply is on top of the TRANSFER receipts (which already equal
demand). So every week the store receives demand + 7%×demand but only consumes
demand → the 7% repair surplus accumulates monotonically above SS.
Confirmed by simulation:
wk=0 sr=3.572 rr=0.089 dem=1.272 avail=6.361 carry=5.089 (surplus=0.089)
wk=1 sr=1.005 rr=0.070 dem=1.005 avail=6.164 carry=5.159 (surplus=0.159)
...
final carry = 7.87 (= 5.0 SS + 2.87 accumulated repair returns)
This matches the observed projection exactly.
Root cause — double supply from repair autofill¶
The Python _allocate_transfers sizes TRANSFER receipts to cover demand + the
SS gap, without knowing that the Rust netting will also add autofilled repair
returns. The two supply sources are additive:
TRANSFER receipts (Python) = demand + SS_top_up ← covers full demand
Repair returns (Rust autofill) = demand × 7% ← surplus on top
─────────────────────────────────────────────────────────
Total supply = demand × 1.07 + SS_top_up
For low-demand/intermittent series the ~7% weekly surplus is never consumed (demand is too small to draw it down), so it accumulates and the projected inventory line climbs monotonically above the safety-stock line for the entire 52-week horizon.
The Python allocation's own inventory tracker stayed at 5.0 because it doesn't account for repair returns — only the Rust netting adds them, creating the divergence between the allocation's view and the stored projection.
The red herring — allocation cover buffer¶
During investigation, the Rust allocate_transfers cover buffer
(lead_time + 3 hardcoded at allocation.rs:347) looked like the cause. It was
fixed anyway because:
- It is a genuine latent bug — the Python mirror
_allocate_transferstargets exactly SS (no forward cover), so the Rust path had diverged from its own docstring ("faithfully mirrors the Python"). - It was made configurable (
allocation_cover_weeks, default 0) so the buffer can be re-enabled per scenario if any pipeline needs it. - It only affects the Rust allocation path, which is currently disabled in production — so it is a forward-looking fix, not the production fix.
The production fix is the Python repair-returns offset (below).
The fix¶
Two changes: a production fix (Python) and a forward-looking fix (Rust).
1. Python — offset TRANSFER receipts by expected repair returns (production fix)¶
files/supply_runner.py — _allocate_transfers:
- Pre-compute
expected_repair[(item_id, site_id)]for every leaf store by mirroring the Rustautofill_repair_returnslogic (includingturnaround=0, which the existing Python_autofill_repair_returnshelper incorrectly skips). - In the leaf-store need computation, subtract the expected repair returns:
- Update the
dest_inv_trackerat all three sites (idle, no-transfer, active) to add repair returns, keeping the tracker in sync with the Rust netting.
This means: when repair returns will cover the week's demand gap, the TRANSFER receipt is reduced (or skipped entirely), eliminating the double supply.
2. Rust — configurable allocation cover buffer (forward-looking fix)¶
EngineConfig.allocation_cover_weeks(engine.rs) — new field, default 0.allocate_transfers(allocation.rs) — acceptscover_weeks, replaces the hardcodedlead_time + 3. With 0, the look-ahead loop is empty and need collapses tomax(0, SS - inv_after_demand)= the Python target-SS formula.supply_runner.py— wiresallocation_cover_weeksintoengine_cfg(default 0)- the supply config defaults + validation.
Before / after data¶
Series 16_301 (pipeline 4, archive 2026-06-15)¶
| Metric | Before | After |
|---|---|---|
| Projected inventory (min–max) | 5.09 – 7.83 | 5.00 – 5.46 |
| Projected inventory final (wk 51) | 7.87 | 5.13 |
| Avg projected inventory | 6.54 | 5.21 |
| Total supply_received | 42.77 | 40.50 |
| Shortage weeks | 0 | 0 |
| TRANSFER orders | 52 (qty 72) | 45 (qty 65) |
The inventory now oscillates tightly around the safety-stock line (5.0) instead
of climbing. Weeks 41 and 49 have supply_received = 0 — the allocation correctly
skipped the transfer because repair returns alone covered the demand gap.
Files changed¶
| File | Change |
|---|---|
files/supply_runner.py |
_allocate_transfers: pre-compute expected_repair mirroring Rust autofill; offset leaf-store need + tracker by repair returns. Also wires allocation_cover_weeks into engine_cfg + defaults + validation. |
files/supply/src/engine.rs |
EngineConfig.allocation_cover_weeks field (default 0); pass to allocate_transfers. |
files/supply/src/allocation.rs |
allocate_transfers accepts cover_weeks; replaces hardcoded lead_time + 3. Two new tests (cover=0 no-drift, cover=4 buffer). |
Validation¶
- Rust tests:
cargo test --lib allocation:: netting::— 8 passed, 0 failed. - Pipeline re-run: pipeline 4 supply run completed;
order_count33285→33009 (fewer transfers),shortage_countunchanged (9561, no new shortages). - Series 16_301: inventory oscillates 5.0–5.46 (was 5.09→7.87), 0 shortage weeks, transfers reduced 52→45.
- Pre-existing test failures: 2
score_alloctests fail on the clean baseline too (unrelated to this change).
Risks & guards¶
- The
expected_repaircomputation mirrors Rust'sautofill_repair_returnsbut is a separate implementation. If the Rust autofill logic changes, this Python mirror must be kept in sync. The comment in the code flags this. - The
expected_repairdemand basis mirrors Rust's forecast-consumption ordering: Rust runsapply_forecast_consumptionbeforeautofill_repair_returns(preprocess.rs:49-50), so the Python_consumed_demandhelper applies the same forward/backward-fence consumption to a local demand copy before computing repair returns. Without this, series with firm customer orders would over-offset transfers. - For stores with confirmed repair returns (non-zero
repair_returnsarray), Rust skips autofill entirely (preprocess.rs:129-131) — it does not take a per-week max. The Python mirror uses the confirmed array as-is, matching Rust. - The
allocation_cover_weeksRust field is currently inert in production (Rust allocation disabled) but is the rollback lever if Rust allocation is re-enabled and a pipeline needs the cover buffer.
Out of scope¶
- MEIO
committed_bufferrecalculation (no change — it remains the SS source of truth). - Score-based allocation engine (
files/supply/src/score_alloc) — independent path. - Frontend chart rendering.
- The 2 pre-existing
score_alloctest failures.