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 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?
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).
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
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.
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.
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)
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.
The Rust allocate_transfers cover buffer (lead_time + 3
hardcoded at allocation.rs:347) looked like the cause. It was
fixed anyway because:
allocation_cover_weeks, default 0).files/supply_runner.py — _allocate_transfers:
expected_repair[(item_id, site_id)] for every leaf store, mirroring the Rust autofill_repair_returns logic (including turnaround=0).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)
dest_inv_tracker at all three sites (idle, no-transfer, active) to add repair returns, keeping the tracker in sync with the Rust netting.EngineConfig.allocation_cover_weeks (engine.rs) — new field, default 0.allocate_transfers (allocation.rs) — accepts cover_weeks, replaces hardcoded lead_time + 3. With 0, the look-ahead loop is empty → need collapses to max(0, SS - inv_after_demand).supply_runner.py — wires allocation_cover_weeks into engine_cfg (default 0) + config defaults + validation.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) |
supply_received = 0 — the allocation correctly skipped the transfer because repair returns alone covered the gap.| File | Change |
|---|---|
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.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. |
cargo test --lib allocation:: netting:: — 8 passed, 0 failed.order_count 33285→33009, shortage_count unchanged (9561, no new shortages).score_alloc tests fail on clean baseline too (unrelated).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.max(confirmed, autofill). The Python mirror uses confirmed values as a lower bound — correct when confirmed ≥ autofill, slightly conservative otherwise.allocation_cover_weeks Rust field is currently inert in production (Rust allocation disabled) but is the rollback lever if Rust allocation is re-enabled.