MEIO V3 — Empirical Bootstrap Enhancement: Implementation & Validation Plan¶
Implementation status — all phases shipped
Phases A, B, C, D, E, F are implemented and validated:
- A —
PIPE_inventory_errorstable +_build_forecast_error_distributions(meio_runner.py); Rustcompound_samplesign-convention fix (forecast-error is diagnostic-only); P98 added toquantiles()/MCSummary/LeadTimeDistribution. - B —
BucketedEmpiricalDistributionTypevariant + planned-LT bucket selection (shrink to pooled); Python_lt_bucket+ per-bucket sample build. - C —
BootstrapMode(Single/Block) config +ReplenishmentTrajectory+BlockBootstrapMCRust engine;monte_carlo::resolve_compound_distributionsblock path; Python trajectory population. - D —
GET /api/meio/skus/{item_id}/{site_id}/diagnostics(three error distributions, percentile table, live service-level curve, simulation histogram). - E —
effective_order_quantity(EOQ)constraint stack (max EOQ/MOQ/lot → round up case-pack/pallet/truckload/container);order_quantityonSkuResult;files/inventory_engine/Python facade (bootstrap/distributions/rop/eoq/diagnostics/simulation). - F —
apply_meio_bootstrap_migrationwired intoapply_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):
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_receipt → PIPE_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):
- Load orders:
demand_actualsrows withreceived_date IS NOT NULL(already grouped in the existing function — reuseby_key). - For each order compute
actual_lt_days = (received_date - date).daysandactual_during_actual_lt(already computed asdemand_during_lt). - Resolve
planned_lt_daysfrom the primary route (same CTE as_build_and_attach_lead_time_distributionstier 3). - Resolve
forecast_during_planned_lt: queryPIPE_backtest_forecastfor the largestforecast_origin ≤ order_date, bestmethod, sumpoint_forecastover horizon steps whoseforecast_origin + horizon_step*7falls in[order_date, order_date + planned_lt_days). If no backtest row, tryPIPE_forecast_point_valuesbyarchive_date ≤ order_date; elsesource='skipped'. inventory_error = actual_during_actual_lt − forecast_during_planned_lt.- Build the empirical error pool → attach
sku["forecast_error_distribution"] = {"type":"empirical","samples":[errors]}. - Persist per-
lt_bucketrows toPIPE_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_distributionas the actual pool (used for ROP), and treatforecast_error_distributionas diagnostic-only — do not add it incompound_sample. This preserves ROP equivalence and avoids the double-count trap entirely. Remove thedemand += 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 areforecast_ltd + error_i. Use it only whencfg.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):
- 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_sampledoes not add forecast_error); P98 present inquantiles().distributions.rs:BucketedEmpiricalselects the bucket containingplanned_lt_days; falls back to pooled when bucket n < n_min.monte_carlo.rs:BlockBootstrapMCdeterministic with seed; block P95 ≥ single P95 on a positively-correlated fixture;block_length=2resamples consecutive pairs.sku.rs:effective_order_quantityrounding across case_pack/pallet/truckload.
F.4 Non-regression¶
use_empirical_distributions=falsepath unchanged (Phase-0 A/B gate) — rerun the existing V3 isolated test (SKUs A/B/C) and confirm byte-identicalcommitted_buffer.use_lead_time_distribution=false+bootstrap_mode=Singlereproduces currentCompoundPoissonEmpiricalconvolution 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¶
Step 4 — Run the V3 pipeline step¶
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 MEIOV3D → Diagnostics 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 onSkuRecord; 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 |