Supply Inventory Drift Above Safety Stock — Root-Cause & Fix

Date: 2026-06-28  |  Series: 16_301 (item 16, site 301, pipeline 4)  |  Markdown: supply-inventory-drift-fix.md
  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

1. 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 horizon40.47
Total supply_received42.77 +2.3 surplus
Projected inventory week 0 → 515.09 → 7.83 (monotonic climb)
TRANSFER orders52 (one every week, qty 1–2, total 72)
Projected inventory (before fix) Projected inventory (after fix) Safety stock (5.0)

2. Investigation trail

Step 1 — Netting logic (initial suspect)

The Rust netting_pass (supply/src/netting.rs:18) computes carry = (available - demand).max(0.0) — it keeps everything above demand, including surplus above SS. This pointed at where does the surplus come from?

Step 2 — Allocation cover buffer (red herring)

The Rust allocate_transfers had a hardcoded cover_weeks = lead_time + 3 forward look-ahead, driving the target to SS + 5×demand ≈ 9.25. But the production path does not use Rust allocation (disabled in favour of Python _allocate_transfers).

Step 3 — Python allocation tracker

Instrumenting the Python allocation showed the tracker correctly held the store at SS=5.0. The Python allocation was not the source of the drift.

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

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. Week 0: SR=3.572 vs demand=1.272 (+2.3 SS top-up). Weeks 1+: SR=demand exactly (net zero). Yet the Rust result showed inventory climbing.

Step 5 — f32 rounding hypothesis (false lead)

In f32, 2.7f32 + 3.572f32 = 6.272037 (overshoot by ~3.7e-5). But simulating the exact f32 netting produced carry=5.0 exactly. f32 rounding was not the cause.

Step 6 — Repair returns autofill (the real cause) FOUND

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% surplus accumulates above SS.

wk=0  sr=3.572  rr=0.089  dem=1.272  → carry=5.089  (surplus=0.089)
wk=1  sr=1.005  rr=0.070  dem=1.005  → carry=5.159  (surplus=0.159)
...
final carry = 7.87  (= 5.0 SS + 2.87 accumulated repair returns)
This matches the observed projection exactly.

3. 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, so it accumulates and the projected inventory climbs monotonically above SS.

4. The red herring — allocation cover buffer

The Rust allocate_transfers cover buffer (lead_time + 3 hardcoded at allocation.rs:347) looked like the cause. It was fixed anyway because:

The production fix is the Python repair-returns offset, not the Rust cover buffer.

5. The fix

1. Python — offset TRANSFER receipts by expected repair returns production fix

files/supply_runner.py_allocate_transfers:

2. Rust — configurable allocation cover buffer forward-looking fix

6. Before / after data

Series 16_301 (pipeline 4, archive 2026-06-15)

MetricBeforeAfter
Projected inventory (min–max)5.09 – 7.835.00 – 5.46
Projected inventory final (wk 51)7.875.13
Avg projected inventory6.545.21
Total supply_received42.7740.50
Shortage weeks00
TRANSFER orders52 (qty 72)45 (qty 65)
Inventory now oscillates tightly around SS (5.0) instead of climbing. Weeks 41 & 49 have supply_received = 0 — the allocation correctly skipped the transfer because repair returns alone covered the gap.

7. Files changed

FileChange
files/supply_runner.py_allocate_transfers: pre-compute expected_repair mirroring Rust autofill; offset leaf-store need + tracker by repair returns. Wires allocation_cover_weeks into engine_cfg + defaults + validation.
files/supply/src/engine.rsEngineConfig.allocation_cover_weeks field (default 0); pass to allocate_transfers.
files/supply/src/allocation.rsallocate_transfers accepts cover_weeks; replaces hardcoded lead_time + 3. Two new tests.

8. Validation

9. Risks & guards