Skip to content

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

  1. Symptom
  2. Investigation trail
  3. Root cause — double supply from repair autofill
  4. The red herring — allocation cover buffer
  5. The fix
  6. Before / after data
  7. Files changed
  8. Validation
  9. Risks & guards
  10. 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:

let cover_weeks = (edge.lead_time as usize + 3).min(WEEKS - w - 1);

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:

{"source_type": "Repair", "return_rate": 1.0, "repair_yield": 0.07, "repair_turnaround": 0}

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:

  1. It is a genuine latent bug — the Python mirror _allocate_transfers targets exactly SS (no forward cover), so the Rust path had diverged from its own docstring ("faithfully mirrors the Python").
  2. It was made configurable (allocation_cover_weeks, default 0) so the buffer can be re-enabled per scenario if any pipeline needs it.
  3. 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 Rust autofill_repair_returns logic (including turnaround=0, which the existing Python _autofill_repair_returns helper incorrectly skips).
  • In the leaf-store need computation, subtract the expected repair returns:
    inv_after_demand = d_inv - d_dem + d_rr_w   # was: d_inv - d_dem
    dest_needs[d_site] = max(0.0, d_ss_w - inv_after_demand)
    
  • Update the dest_inv_tracker at 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) — accepts cover_weeks, replaces the hardcoded lead_time + 3. With 0, the look-ahead loop is empty and need collapses to max(0, SS - inv_after_demand) = the Python target-SS formula.
  • supply_runner.py — wires allocation_cover_weeks into engine_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_count 33285→33009 (fewer transfers), shortage_count unchanged (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_alloc tests fail on the clean baseline too (unrelated to this change).

Risks & guards

  • The expected_repair computation mirrors Rust's autofill_repair_returns but 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_repair demand basis mirrors Rust's forecast-consumption ordering: Rust runs apply_forecast_consumption before autofill_repair_returns (preprocess.rs:49-50), so the Python _consumed_demand helper 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_returns array), 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_weeks Rust 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_buffer recalculation (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_alloc test failures.