Skip to content

MEIO V3 — Empirical Bootstrap Enhancement: Implementation & Validation Plan

Implementation status — all phases shipped

Phases A, B, C, D, E, F are implemented and validated:

  • APIPE_inventory_errors table + _build_forecast_error_distributions (meio_runner.py); Rust compound_sample sign-convention fix (forecast-error is diagnostic-only); P98 added to quantiles()/MCSummary/LeadTimeDistribution.
  • BBucketedEmpirical DistributionType variant + planned-LT bucket selection (shrink to pooled); Python _lt_bucket + per-bucket sample build.
  • CBootstrapMode (Single/Block) config + ReplenishmentTrajectory + BlockBootstrapMC Rust engine; monte_carlo::resolve_compound_distributions block path; Python trajectory population.
  • DGET /api/meio/skus/{item_id}/{site_id}/diagnostics (three error distributions, percentile table, live service-level curve, simulation histogram).
  • Eeffective_order_quantity(EOQ) constraint stack (max EOQ/MOQ/lot → round up case-pack/pallet/truckload/container); order_quantity on SkuResult; files/inventory_engine/ Python facade (bootstrap/distributions/rop/ eoq/diagnostics/simulation).
  • Fapply_meio_bootstrap_migration wired into apply_history_schema; apply_meio_bootstrap_schema.py; seed_v3_meio_phase_f.py; Rust unit tests for P98, sign convention, bucket selection, order-quantity rounding, block determinism.

Validation smoke tests: files/MEIO_v3/tests_smoke_block.py, files/MEIO_v3/tests_smoke_bucketed.py. Rust: 69 passed (4 pre-existing failures in target.rs/sku.rs::effective_lot_size are unrelated — confirmed zero diff in target.rs, logic-only field additions in sku.rs).

What this chapter is

A complete, step-by-step plan to close the gaps between the current MEIO V3 engine and the empirical-bootstrap-replenishment-cycle specification, plus a full validation protocol to prove each phase end-to-end. It is written for the external documentation site (this page renders to HTML via mkdocs build).

Every phase lists: the gap it closes, the exact files/functions touched, a code sketch, a worked numeric example, the data sources used, and the assertion that proves it. Diagrams are Mermaid (rendered inline); sample outputs are ASCII tables so they are copy-paste verifiable.

For the current V3 engine mechanics, see MEIO V3 — Empirical Distributions, Monte-Carlo & RL and the V3 isolated test guide.


0. Status snapshot — what V3 already does

Spec principle V3 today Verdict
No ROP = Mean + Z·Sigma; no Normal assumption Empirical CDF / linear-interp quantiles
Use empirical replenishment-cycle uncertainty demand_over_lt_distribution (actual demand-during-actual-LT)
ROP = percentile(simulated_ltd, SL); SS = ROP − ForecastLTD Optimizer searches buffer to hit group fill-rate = empirical percentile ✅ (equivalent)
Cycle-stock baseline consistent with the empirical horizon effective_total_lt_fcst = dist.empirical_mean() overrides demand_rate × planned_LT (distributions.rs + marginal.rs)
Empirical mode bypasses forecast confidence levels for safety stock V3 builds demand-over-LT distributions from master.demand_actuals; fitted dists are fallback (priority 2)
Bootstrap uses raw errors; MAE/RMSE diagnostic-only No MAE/RMSE stored (no violation) — but error-vs-forecast not stored ⚠️ G1
Lead-time bucketing (0-7 / 8-14 / 15-30 / 31-60 / 61+) One pooled distribution per (item, site) ❌ G2
Block bootstrap (trajectories, LT↔demand correlation) iid resampling; convolution draws LT & demand independently ❌ G3
Lead-time error distribution (actual vs planned) LT stats stored, not paired with planned LT ⚠️ G4
Diagnostics: 3 error distributions, SL curve, histogram, P50/90/95/98/99 Demand-over-LT + LT stats only; no P98, no SL curve, no histogram ❌ G5
EOQ sqrt(2DS/H), decoupled from ROP kcurve_rs/eoq.rs ✅; max(EOQ,MOQ,lot,case,pallet,truck,container) ⚠️ G6
Package inventory_engine/{bootstrap,distributions,rop,eoq,diagnostics,simulation}.py Rust PyO3 extension + meio_runner.py ⚠️ G7

Key equivalence note (why G1 does not change ROP):

\[\text{ROP} = \text{percentile}\!\bigl(\text{forecast\_ltd} + \text{error}_i\bigr) = \text{percentile}\!\bigl(\text{actual}_i\bigr)\]

because \(\text{error}_i = \text{actual}_i - \text{forecast\_ltd}\) is a constant shift. V3 uses the actual-demand-over-LT pool directly — the exact, lower-variance form of the same estimator. The forecast-error distribution is therefore needed only for diagnostics (the spec's three error distributions) and for the block-bootstrap correlation structure — never for ROP arithmetic itself.


1. Data sources (resolved)

The hardest open question was where does forecast_demand_during_planned_lt come from? PIPE_forecast_point_values has no forecast_origin column and only weekly archive_date partitions — not aligned to arbitrary past order dates. The resolved source is the backtest table, which simulates exactly "forecast made at origin, what actually happened over the horizon":

-- files/DDL/ch_schema.sql:522
CREATE TABLE PIPE_backtest_forecast (
  pipeline_id, scenario_id, archive_date Date,
  item_id Int64, site_id Int64, method String,
  forecast_origin Date,        -- ← the Monday the forecast was made
  horizon_step Int32,          -- ← weeks ahead (1,2,3,…)
  point_forecast Float64,      -- ← forecast for that week
  actual_value Nullable(Float64), -- ← what actually happened
  ...
)
Diagnostic field Source Join key
actual_demand_during_actual_lt master.demand_actuals (qty summed over [date, date+actual_lt)) (item_id, site_id) + date window
forecast_demand_during_planned_lt PIPE_backtest_forecast.point_forecast summed over horizon steps covering the planned-LT window, for the origin ≤ order date (item_id, site_id), forecast_origin ≤ order_date, horizon ∈ [order_date, order_date+planned_lt)
planned_lead_time_days master.route (lead_time + transit_time + pick_pack_time) of the primary BUY/TRANSFER/MAKE route (item_id, site_id)
actual_lead_time_days master.demand_actuals.received_date − date (existing) per order
Lead-time actuals (supply) master.master_purchase_receiptPIPE_lead_time_actuals → route-synthesized (existing _build_and_attach_lead_time_distributions) existing

Fallback when no backtest origin exists for an order date: use the most recent planning-Monday archive_date ≤ order_date from PIPE_forecast_point_values; if none, skip that order for the forecast-error diagnostic (the actual-demand-over-LT pool — used for ROP — is unaffected).


2. Target architecture

flowchart TD
    subgraph Python["meio_runner.py (orchestration)"]
        A[master.demand_actuals<br/>+ received_date] --> B[_build_empirical_distributions]
        C[PIPE_backtest_forecast<br/>+ master.route planned LT] --> D[_build_forecast_error_distributions<br/>NEW G1/G4]
        E[master.master_purchase_receipt<br/>PIPE_lead_time_actuals<br/>master.route] --> F[_build_lead_time_distributions]
        B --> G[_build_bucketed_distributions<br/>NEW G2]
        D --> G
        F --> G
        G --> H[_build_compound_demand_over_lt<br/>trajectory tuples NEW G3]
        H --> I[SKU JSON payload]
    end
    subgraph Rust["MEIO_v3/src (Rust PyO3)"]
        I --> J[resolve_compound_distributions<br/>block-bootstrap MC NEW G3]
        J --> K[fill_rate_for_rop<br/>empirical CDF + bucket select NEW G2]
        K --> L[Optimizer greedy loop<br/>group fill-rate target]
        L --> M[committed_buffer = ROP]
        M --> N[effective_order_quantity<br/>max EOQ MOQ lot case pallet truck NEW G6]
    end
    subgraph Diag["Diagnostics NEW G5"]
        B --> O[PIPE_uncertainty_distributions<br/>+ P98]
        D --> P[PIPE_inventory_errors<br/>NEW table]
        F --> Q[PIPE_lead_time_stats<br/>+ lt_error cols]
        K --> R[/api/meio/.../diagnostics<br/>SL curve + histogram NEW]
    end

3. Phase A — Forecast-error / inventory-error distribution (G1, G4, G5-partial)

Goal

Make the spec's central object — inventory_error = actual_demand_during_actual_lt − forecast_demand_during_planned_lt — a first-class, populated distribution; pair planned vs actual lead time; add P98.

A.1 New ClickHouse table

files/DDL/ch_schema.sql — append:

CREATE TABLE IF NOT EXISTS PIPE_inventory_errors
(
    pipeline_id      Int64,
    scenario_id      Int64,
    archive_date     Date DEFAULT toMonday(today()),
    item_id          Int64,
    site_id          Int64,
    item_name        String,
    site_name        String,
    lt_bucket        LowCardinality(String),   -- '0-7','8-14','15-30','31-60','61+'
    planned_lt_days  Float64,
    actual_lt_days   Float64,
    lt_error_ratio   Float64,                  -- actual / planned
    forecast_during_planned_lt Float64,
    actual_during_actual_lt     Float64,
    inventory_error  Float64,                  -- actual − forecast
    n_obs            UInt32,
    err_mean Float64, err_std Float64, err_skew Float64, err_kurt Float64,
    err_p50 Float64, err_p90 Float64, err_p95 Float64, err_p98 Float64, err_p99 Float64,
    source           LowCardinality(String)    -- 'backtest' | 'archive_fcst' | 'skipped'
)
ENGINE = ReplacingMergeTree(archive_date)
PARTITION BY (pipeline_id, scenario_id, archive_date)
ORDER BY (pipeline_id, scenario_id, item_id, site_id, lt_bucket)
SETTINGS index_granularity = 8192;

Add PIPE_uncertainty_distributions and PIPE_lead_time_stats ALTER … ADD COLUMN p98 Float64 DEFAULT 0 (idempotent) so the percentile table carries the spec's full set.

A.2 Python — _build_forecast_error_distributions (new)

files/meio_runner.py — new function next to _build_and_attach_empirical_distributions (line ~1993). Per (item, site):

  1. Load orders: demand_actuals rows with received_date IS NOT NULL (already grouped in the existing function — reuse by_key).
  2. For each order compute actual_lt_days = (received_date - date).days and actual_during_actual_lt (already computed as demand_during_lt).
  3. Resolve planned_lt_days from the primary route (same CTE as _build_and_attach_lead_time_distributions tier 3).
  4. Resolve forecast_during_planned_lt: query PIPE_backtest_forecast for the largest forecast_origin ≤ order_date, best method, sum point_forecast over horizon steps whose forecast_origin + horizon_step*7 falls in [order_date, order_date + planned_lt_days). If no backtest row, try PIPE_forecast_point_values by archive_date ≤ order_date; else source='skipped'.
  5. inventory_error = actual_during_actual_lt − forecast_during_planned_lt.
  6. Build the empirical error pool → attach sku["forecast_error_distribution"] = {"type":"empirical","samples":[errors]}.
  7. Persist per-lt_bucket rows to PIPE_inventory_errors.
# sketch — files/meio_runner.py
def _build_forecast_error_distributions(schema, skus, pipeline_id, n_min=3):
    from utils.pipeline_guard import require_pipeline_id
    from utils.planning_date import planning_monday
    _pid = require_pipeline_id(pipeline_id, "forecast_error_distributions")
    archive_date = planning_monday(pipeline_id)
    # planned LT per (item,site) — reuse the tier-3 route CTE result
    planned_lt_by_key = _load_planned_lt_by_key(schema)          # G4 helper
    # backtest forecast lookup keyed by (item,site)
    bt_by_key = _load_backtest_forecast_by_key(pipeline_id)      # G1 helper
    rows_to_persist = []
    for sku in skus:
        key = (sku["item_id"], sku["site_id"])
        orders = _orders_with_actual_lt_by_key.get(key, [])      # reused from Phase-1 fn
        planned = planned_lt_by_key.get(key)
        if not planned or not orders: continue
        bt = bt_by_key.get(key, {})
        errors, lt_errors, recs = [], [], []
        for o in orders:
            alt = o["actual_lt_days"]; adalt = o["actual_during_actual_lt"]
            fdplt, src = _forecast_during_planned_lt(bt, o["date"], planned, archive_date)
            if src == "skipped": continue
            errors.append(adalt - fdplt)
            lt_errors.append(alt / planned if planned > 0 else 1.0)
            recs.append((alt, planned, fdplt, adalt, adalt - fdplt, src))
        if len(errors) < n_min: continue
        sku["forecast_error_distribution"] = {"type": "empirical",
                                              "samples": sorted(errors)}
        rows_to_persist.extend(_to_inventory_error_rows(
            sku, _pid, archive_date, recs, errors, lt_errors))
    _persist_inventory_errors(rows_to_persist)

A.3 Rust — fix the sign convention (G1-correctness)

files/MEIO_v3/src/uncertainty.rs:316 compound_sample currently does demand += fe.sample(). With G1 wired, the error pool is the inventory error, so the bootstrap must be forecast_ltd + sampled_error, not demand + error (that double-counts). Two clean options:

  • Option 1 (recommended): keep demand_over_lt_distribution as the actual pool (used for ROP), and treat forecast_error_distribution as diagnostic-only — do not add it in compound_sample. This preserves ROP equivalence and avoids the double-count trap entirely. Remove the demand += fe.sample() line; add a doc note.
  • Option 2: if a caller explicitly wants the error-based path, add a new DistributionType::ForecastErrorEmpirical { forecast_ltd, errors } whose samples are forecast_ltd + error_i. Use it only when cfg.bootstrap_mode == "error".

Decision: Option 1. forecast_error_distribution becomes diagnostic-only; the ROP path keeps using the actual-demand-over-LT pool. This is the lowest-risk, mathematically equivalent choice.

A.4 Rust — add P98

files/MEIO_v3/src/uncertainty.rs:196 — extend the quantile tuple to 8 values [p10,p25,p50,p75,p90,p95,p98,p99]:

pub fn quantiles(&self) -> [f64; 8] {
    [self.inv_cdf(0.10), self.inv_cdf(0.25), self.inv_cdf(0.50),
     self.inv_cdf(0.75), self.inv_cdf(0.90), self.inv_cdf(0.95),
     self.inv_cdf(0.98), self.inv_cdf(0.99)]
}

Update MCSummary (uncertainty.rs:451) with p98, BootstrapMC::run_summary (uncertainty.rs:413), and every quantiles() consumer. Update Python percentile list in _build_and_attach_empirical_distributions (meio_runner.py:2166) and the PIPE_uncertainty_distributions insert.

A.5 Worked example (Phase A)

Using the spec's Order O1 with planned LT = 10, forecast-during-planned-LT = 1000, actual LT = 14, actual-during-actual-LT = 1450:

inventory_error = 1450 − 1000 = +450
lt_error_ratio = 14 / 10 = 1.40
lt_bucket = '8-14'      (actual LT falls in 8-14; planned LT 0-7 → record both)

A 7-order history produces the error pool the spec gives:

errors = [200, -50, 450, -50, 100, -200, 600]
  mean =  78.6   std = 273.0   skew = 0.71   kurt(excess) = 0.34
  p50 = -50   p90 = 350   p95 = 480   p98 = 564   p99 = 600

Assertion (validation): after Phase A, for a seeded SKU with known forecast bias, SELECT err_mean FROM PIPE_inventory_errors WHERE item_id=… ≈ the seeded bias (±tol), and err_p98 is populated (non-zero).


4. Phase B — Lead-time bucketing (G2)

Goal

Separate bootstrap pools by lead-time horizon because forecast uncertainty grows with the replenishment horizon. Never mix a 3-day expedite with a 45-day ocean order.

B.1 Bucket classifier

files/meio_runner.py — new constant + helper:

LT_BUCKETS = [("0-7",0,7), ("8-14",8,14), ("15-30",15,30),
              ("31-60",31,60), ("61+",61,10**9)]
def _lt_bucket(days: float) -> str:
    for name, lo, hi in LT_BUCKETS:
        if lo <= days <= hi: return name
    return "61+"

B.2 Bucketed demand-over-LT pools

In _build_and_attach_empirical_distributions, instead of one samples list per (item,site), build samples_by_bucket: dict[str, list[float]] keyed by _lt_bucket(actual_lt_days). Attach:

sku["demand_over_lt_distribution"] = {
    "type": "bucketed_empirical",
    "planned_lt_days": planned,
    "buckets": {b: {"type":"empirical","samples":s} for b,s in samples_by_bucket.items()},
}

B.3 Rust — BucketedEmpirical variant + planned-LT selection

files/MEIO_v3/src/distributions.rs — new variant:

BucketedEmpirical {
    planned_lt_days: f64,
    buckets: std::collections::BTreeMap<String, DistributionType>, // bucket -> Empirical
}

In DistributionType::resolve / fill_rate_for_rop, when the variant is BucketedEmpirical, select the bucket whose range contains planned_lt_days (the horizon the next order will face — that is the uncertainty that applies). Fall back to the nearest bucket, then to the pooled distribution if a bucket has < n_min samples (shrinkage toward pooled).

flowchart LR
    P[planned_lt_days = 28] --> S{bucket select}
    S -->|28 ∈ 15-30| B[15-30 Empirical pool]
    B --> F[fill_rate_for_rop]
    S -.->|n < n_min| POOL[pooled Empirical shrink]
    POOL --> F

B.4 Worked example (Phase B)

Same SKU, two supply modes mixed in history:

Order actual LT (d) bucket demand-during-actual-LT
#1 5 0-7 110
#2 6 0-7 130
#3 28 15-30 410
#4 31 31-60 520

Pooled (current V3): samples = [110,130,410,520] → P95 ≈ 520 (one tail point). Bucketed: for a new order with planned LT = 28d → use 15-30 bucket [410] (shrink toward pooled because n=1 < 30). For planned LT = 5d → 0-7 bucket [110,130].

Assertion: for a SKU with planned LT in 61+, the bucketed ROP (using the 61+ pool) must be the pooled ROP at the same service level, because the long-horizon pool carries the fatter tail. Quantify the delta on the seeded 4th SKU (see §9).


5. Phase C — Block bootstrap / trajectory correlation (G3) ★ highest impact

Goal

Sample complete replenishment-cycle trajectories (lt_days, demand_during_lt) as pairs, preserving LT↔demand correlation, supplier-delay clusters, seasonal co-occurrence. This is the spec's preferred advanced mode.

C.1 Why it matters

The current CompoundPoissonEmpirical convolution draws LT and demand from separate marginals (monte_carlo.rs:80-92). This destroys joint structure: a 45-day delayed shipment that coincided with a promo demand spike is split into an independent LT draw and an independent demand draw. Block bootstrap keeps the pair intact.

flowchart TD
    subgraph Single["Single-error / iid (current)"]
        D1[draw demand_i] ~~~ L1[draw lt_j] --> S1[sum demand over lt_j periods]
    end
    subgraph Block["Block bootstrap NEW"]
        T[(lt_k, demand_during_lt_k) pair] --> S2[use the whole trajectory k]
    end
    Block -->|preserves| CORR[LT↔demand correlation<br/>supplier-delay clusters<br/>seasonal spikes]

C.2 Config switch

files/MEIO_v3/src/config.rs — add:

#[serde(default = "default_bootstrap_mode")]
pub bootstrap_mode: BootstrapMode,   // Single | Block (default Block per spec)
#[serde(default = "default_block_length")]
pub block_length: usize,             // consecutive orders per block; 1 = pure pair resample

with enum BootstrapMode { Single, Block }, default Block, block_length default 1.

C.3 Trajectory storage + Rust engine

files/MEIO_v3/src/uncertainty.rs — new:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplenishmentTrajectory {
    pub lt_days: f64,
    pub demand_during_lt: f64,
}

pub struct BlockBootstrapMC { rng: SmallRng, block_length: usize }
impl BlockBootstrapMC {
    /// Resample whole (lt, demand) pairs (block_length=1) or consecutive
    /// blocks of `block_length` orders, preserving their joint structure.
    pub fn run(&mut self, trajs: &[ReplenishmentTrajectory], n_iter: usize)
        -> Vec<f64> { /* draw block, push demand_during_lt per draw */ }
}

files/MEIO_v3/src/sku.rs — add pub replenishment_trajectories: Option<Vec<ReplenishmentTrajectory>>. Python _build_compound_demand_over_lt (meio_runner.py:2589) populates it from the (_lt_days_samples, demand-over-LT) pairs it already has.

files/MEIO_v3/src/monte_carlo.rs::resolve_compound_distributions — when cfg.bootstrap_mode == Block and replenishment_trajectories is present, run BlockBootstrapMC instead of the independent convolution.

C.4 Worked example (Phase C)

Trajectories (positively correlated — long LT ⇒ more demand accumulated):

trajs = [(lt=7,  d=100), (lt=14, d=210), (lt=21, d=330), (lt=45, d=720)]
  • Single/iid draws LT and demand independently → can sample (lt=45, d=100), an impossible combo that understates tail risk.
  • Block resamples whole pairs → only real combinations appear; P95 of demand-over-LT is higher because the (45, 720) pair is preserved intact.

With 10 000 block draws (seed=42), expect P95_block > P95_single whenever LT and demand are positively correlated. Assertion: on a seeded SKU with positive LT↔demand correlation, block P95 − single P95 > 0, and block ROP ≥ single ROP at SL=0.95.


6. Phase D — Diagnostics artifacts (G5)

Goal

Deliver the spec's outputs: service-level curve, simulation histogram, percentile table (P50/90/95/98/99), and the three error distributions.

D.1 API endpoint

files/api/main.py (or a new files/api/meio_diagnostics_router.py mounted at /api/meio):

GET /api/meio/skus/{item_id}/{site_id}/diagnostics?pipeline_id=…&scenario_id=…
→ {
  "demand_error_distribution":   {mean,std,skew,kurt,p50,p90,p95,p98,p99, samples},
  "lead_time_error_distribution":{…, planned_mean, actual_mean, lt_error_p50…},
  "inventory_error_distribution":{…, from PIPE_inventory_errors},
  "percentile_table": [{service_level:0.90, rop:…, ss:…}, … 0.95,0.98,0.99],
  "service_level_curve": [{buffer:…, fill_rate:…}, … 50 points],
  "simulation_histogram": [{bin_lo,bin_hi,count}, … 40 bins]
}

The service-level curve is computed live: sample 50 buffer values across [forecast_ltd, forecast_ltd + 4·std], call the Rust fill_rate_for_buffer exposed function (lib.rs:268) for each → (buffer, fill_rate) pairs.

The simulation histogram is one BlockBootstrapMC::run (n=mc_iterations) binned into 40 bins; persisted to CH only for the top-N SKUs by demand-weight (size guard), else computed on demand.

D.2 Frontend (lightweight)

files/frontend/src/components/ — a MeioDiagnosticsPanel rendering:

  • error-distribution histogram (recharts BarChart)
  • service-level curve (recharts LineChart, buffer vs fill-rate, with the group target line and the chosen ROP marker)
  • percentile table

Row-click from PIPE_meio_results already routes to the series page; add a "Diagnostics" tab there.

D.3 Worked example (Phase D)

For forecast_ltd = 1200, empirical demand-over-LT samples, SL=0.95:

percentile_table:
  SL=0.90 → ROP=1380, SS=180
  SL=0.95 → ROP=1520, SS=320
  SL=0.98 → ROP=1640, SS=440
  SL=0.99 → ROP=1800, SS=600

service_level_curve (excerpt):
  buffer=1200 → fr=0.50
  buffer=1380 → fr=0.90
  buffer=1520 → fr=0.95   ← group target marker
  buffer=1800 → fr=0.99

Assertion: the buffer at which the live curve crosses the group target equals the optimizer's committed_buffer (within ±1 lot size) — proving the optimizer's ROP is the empirical percentile at the chosen service level.


7. Phase E — Quantity constraints + policy facade (G6, G7)

E.1 Order-quantity constraint stack

files/MEIO_v3/src/sku.rs — add optional fields (default no-op):

#[serde(default)] pub moq: f64,
#[serde(default)] pub case_pack: f64,
#[serde(default)] pub pallet_multiple: f64,
#[serde(default)] pub truckload_qty: f64,
#[serde(default)] pub container_fill: f64,

Replace effective_lot_size() usage for the order quantity (not the EOQ step) with:

pub fn effective_order_quantity(&self, eoq: f64) -> f64 {
    let mut q = eoq.max(self.moq).max(self.lot_size);
    if self.case_pack > 0.0      { q = (q / self.case_pack).ceil()      * self.case_pack; }
    if self.pallet_multiple > 0.0{ q = (q / self.pallet_multiple).ceil()* self.pallet_multiple; }
    if self.truckload_qty > 0.0  { q = (q / self.truckload_qty).ceil()  * self.truckload_qty; }
    if self.container_fill > 0.0 { q = (q / self.container_fill).ceil() * self.container_fill; }
    q
}

This matches the spec's max(EOQ, MOQ, LotSize, CasePack, PalletMultiple, TruckFill, ContainerFill). ROP is untouched — EOQ/quantity constraints affect only how much is ordered, never when (spec: "EOQ does NOT affect ROP").

E.2 Python facade (G7)

files/inventory_engine/ — thin re-export package so external consumers match the spec's prescribed module API without rewriting the Rust engine:

files/inventory_engine/
  __init__.py
  rop.py          # wraps meio_optimizer_v3.fill_rate_for_buffer + percentile helpers
  bootstrap.py    # wraps BlockBootstrapMC via a Python ctypes-free JSON bridge
  distributions.py# wraps DistributionType resolve/empirical CDF
  eoq.py          # wraps kcurve_rs.classic_eoq + effective_order_quantity
  diagnostics.py  # wraps the new /api/meio diagnostics
  simulation.py   # wraps run_convolution_mc / BlockBootstrapMC::run

Each module documents that the heavy loop is Rust-backed (Rayon, GIL-released) — the right production choice vs. a pure-numpy reference implementation.

E.3 Worked example (Phase E)

D=12000, S=100, H=5 → EOQ = sqrt(2·12000·100/5) = 693. With MOQ=500, lot=120, case_pack=24, pallet=240:

q0 = max(693, 500, 120) = 693
case_pack  → ceil(693/24)*24  = 696
pallet     → ceil(696/240)*240 = 720
→ effective_order_quantity = 720

Assertion: effective_order_quantity(693) == 720 for these constraints.


8. Phase F — Validation (cross-cutting)

F.1 Seed extension

files/scripts/seed_v3_meio_isolated.py — add a 4th dedicated SKU MEIOV3D (item id 910104) with:

  • 40 demand rows with received_date, positive LT↔demand correlation (longer shipments carry proportionally more demand)
  • a known forecast bias (backtest point_forecast = actual × 0.85 → +15% bias)
  • mixed lead times spanning all 5 buckets (some 5d expedite, some 45d ocean)
  • MOQ/case_pack/pallet set on its primary route

F.2 Phase-by-phase assertions

Phase Assertion Where checked
A err_mean ≈ +15% × mean_actual (seeded bias); err_p98 non-zero PIPE_inventory_errors query
A forecast_error_distribution populated on the SKU payload Rust debug log / payload dump
B bucketed ROP(61+) ≥ pooled ROP at SL=0.95 PIPE_meio_results compare
B planned-LT bucket selection matches _lt_bucket(planned_lt) unit test + log
C block P95 − single P95 > 0 (positive correlation SKU) diagnostics histogram/percentiles
C block ROP ≥ single ROP at SL=0.95 PIPE_meio_results
D curve-crossing buffer == committed_buffer (±1 lot) /api/meio/.../diagnostics
D percentile table has P50/90/95/98/99 diagnostics JSON
E effective_order_quantity(693) == 720 Rust unit test
E ROP unchanged when MOQ raised (EOQ≠ROP) PIPE_meio_results compare

F.3 Rust unit tests (add to respective #[cfg(test)])

  • uncertainty.rs: error-sign guard (Option 1 — compound_sample does not add forecast_error); P98 present in quantiles().
  • distributions.rs: BucketedEmpirical selects the bucket containing planned_lt_days; falls back to pooled when bucket n < n_min.
  • monte_carlo.rs: BlockBootstrapMC deterministic with seed; block P95 ≥ single P95 on a positively-correlated fixture; block_length=2 resamples consecutive pairs.
  • sku.rs: effective_order_quantity rounding across case_pack/pallet/truckload.

F.4 Non-regression

  • use_empirical_distributions=false path unchanged (Phase-0 A/B gate) — rerun the existing V3 isolated test (SKUs A/B/C) and confirm byte-identical committed_buffer.
  • use_lead_time_distribution=false + bootstrap_mode=Single reproduces current CompoundPoissonEmpirical convolution exactly.

9. Step-by-step validation protocol (drive it end-to-end)

Run order

Follow these commands top-to-bottom; each has an expected result you can eyeball.

Step 1 — Build the Rust extension

cd files\MEIO_v3
maturin develop --release
# expect: 📪 Built new wheel → meio_optimizer_v3.pyd refreshed

Step 2 — Apply schema (idempotent)

# add PIPE_inventory_errors + p98 columns to CH (one-off per tenant)
files\venv\Scripts\python.exe files\scripts\apply_meio_bootstrap_schema.py

Step 3 — Seed the 4th SKU

files\venv\Scripts\python.exe files\scripts\seed_v3_meio_isolated.py --with-d

Step 4 — Run the V3 pipeline step

files\venv\Scripts\python.exe files\meio_runner.py --scenario-ids 24 --pipeline-id 110
Expected log lines (new):
Attached forecast-error distributions to N SKUs (backtest=…, archive_fcst=…, skipped=…)
Built bucketed demand-over-LT for M SKUs across 5 LT buckets
Converted K SKUs to BlockBootstrap trajectories
resolve_compound_distributions — block bootstrap …

Step 5 — Query diagnostics

-- forecast bias check (Phase A)
SELECT item_id, err_mean, err_p98, source, n_obs
FROM PIPE_inventory_errors WHERE pipeline_id=110 AND item_id=910104;

-- bucketed vs pooled ROP (Phase B)
SELECT item_id, lt_bucket, err_p95 FROM PIPE_inventory_errors
WHERE pipeline_id=110 ORDER BY lt_bucket;

-- block vs single ROP (Phase C)
SELECT item_id, committed_buffer, new_fill_rate FROM PIPE_meio_results
WHERE pipeline_id=110 AND item_id=910104;

Step 6 — Inspect the diagnostics panel

Open the series page for MEIOV3DDiagnostics tab → confirm:

  • three error-distribution histograms render
  • service-level curve crosses the group target at the committed buffer
  • percentile table lists P50/90/95/98/99

Step 7 — Non-regression

Re-run SKUs A/B/C with use_empirical_distributions=false; confirm committed_buffer unchanged vs the 2026-06-23 baseline in docs/meio_v3_test_guide_2026-06-23.md → "Actual verified results".

Step 8 — Publish docs to the external site

.\build_docs.bat
# → site\ built, upload site\ to OVH; this page renders as meio-v3-bootstrap-enhancement.html

10. Implementation order & effort

# Phase Gaps closed Touches Risk Seq
1 A G1,G4,G5-P98 CH schema, meio_runner.py, uncertainty.rs Low (additive) 1st
2 C G3 config.rs,uncertainty.rs,monte_carlo.rs,sku.rs Med (Rust) 2nd ★
3 B G2 meio_runner.py,distributions.rs,sku.rs Med (bucket select) 3rd
4 D G5 API, frontend, CH Low 4th
5 E G6,G7 sku.rs,kcurve_rs, new inventory_engine/ Low 5th
6 F seed script, tests throughout

Why C before B: block bootstrap is the single highest-impact accuracy improvement (preserves correlation) and is self-contained — it does not depend on bucketing. B then layers horizon stratification on top of the block engine.


11. Out of scope (explicit, per spec's "future extension")

  • Multi-echelon inventory optimization across the network (V3 already does echelon fill-rate propagation via kit wait-times; full multi-echelon ROP coordination is a later phase).
  • Supplier-specific risk pools as separate bootstrap universes (the LT distribution is already supplier-resolved via master_purchase_receipt + dominant-supplier join; per-supplier demand pools are a follow-up).
  • Route-specific lead-time uncertainty beyond the existing route-synthesis tier.
  • Capacity constraints / stochastic supply.
  • Service-level optimization across a network (budget allocation across groups is already supported; network-wide SL optimization is later).
  • Reinforcement-learning inventory policies — the RL policy layer (risk_multiplier, expedite_threshold, supplier_preference_weights) already exists on SkuRecord; training the policy is a separate track documented in MEIO V3 — RL.

12. Glossary

Term Meaning
ROP Reorder Point — when to order (= the buffer/percentile the optimizer commits)
SS Safety Stock = ROP − ForecastLTD
EOQ Economic Order Quantity — how much to order; decoupled from ROP
LTD Lead-Time Demand — demand accrued over the replenishment lead time
inventory_error actual_demand_during_actual_lt − forecast_demand_during_planned_lt
lt_error actual_lead_time_days / planned_lead_time_days
LT bucket Lead-time horizon class: 0-7 / 8-14 / 15-30 / 31-60 / 61+ days
Block bootstrap Resample whole (lt, demand_during_lt) trajectories, preserving correlation
Shrinkage (E3) Blend empirical quantiles with parametric when n < n_min