MEIO V3 Empirical Bootstrap — Isolated Test Guide¶
Date: 2026-06-23
Tenant: thebicycle
Scope: MEIO V3 empirical-distribution bootstrap → Rust optimizer → safety-stock results — three individually-debuggable SKU situations on isolated, dedicated test items/sites
Script: files/scripts/seed_v3_meio_isolated.py
Seeded pipeline: MEIO V3 Test Lab (pipeline_id=110, meio_scenario_id=24)
TOC¶
- Goal & design rationale
- MEIO V3 mechanics recap
- Environment facts
- The three test scenarios
- Series reference tables
- Seeded data
- Phase-by-phase verification plan
- Actual verified results
- UI validation
- How to drive the test
- V3 vs V2 contrast
- Multithreading verification
- Cleanup
- Design decisions (Q&A)
- Risks & guards
- Out of scope
Goal & design rationale¶
The goal is to isolate three MEIO V3 empirical-bootstrap situations so each
phase of the algorithm can be reviewed and debugged individually, without the
noise of the real 81 895 demand_actuals rows, 690 routes, or 34 existing
MEIO segments.
MEIO loads its SKU universe from global master.demand_actuals (no
pipeline filter — only EOQ + indirect-demand WH injection is pipeline-scoped).
Reusing existing items/sites would let their pre-existing demand, routes and
lead times pollute the V3 empirical arithmetic. The test instead creates
brand-new items and locations in a reserved id range (9101xxx) so the test
sub-graph is fully isolated.
Three dedicated test items, one dedicated test pipeline, one MEIO scenario, one situation per item:
| # | Situation | Item (xuid) | What drives the V3 path |
|---|---|---|---|
| V3-A | Rich empirical — 40 demand rows with received_date (n≥30, no shrinkage) |
MEIOV3A |
BUY route LT=28d (4 wk); bootstrap builds a full empirical demand-over-LT distribution |
| V3-B | Sparse empirical — 15 demand rows with received_date (n<30, shrinkage) |
MEIOV3B |
BUY route LT=14d (2 wk); bootstrap builds an empirical dist, but Rust E3 shrinkage blends it with the parametric fallback |
| V3-C | No empirical — 47 demand rows, NO received_date → parametric fallback |
MEIOV3C |
BUY route LT=35d (5 wk); no received_date → V3 falls back to the parametric normal (dmd_stddev) — identical to V2 |
All demand is seeded on aligned Monday weeks so the bootstrap arithmetic (sum
demand over [date, date + actual_lt_days)) is deterministic and
hand-verifiable: weekly demand is constant (A=12, B=8) and received_date is
jittered so the demand-during-LT window captures 4/5/6/7 weeks (A) or 2/3/4/5
weeks (B) → every sample is an exact multiple of the weekly qty.
MEIO V3 mechanics recap¶
MEIO V3 lives in files/meio_runner.py (Python orchestration) +
files/MEIO_v3/src/ (Rust optimizer, PyO3 module meio_optimizer_v3). The
entry point is run_scenarios(...) (meio_runner.py:94), invoked by the
pipeline step meio (run_pipeline.py:1039) or the API endpoint
POST /api/meio/scenarios/{id}/run.
The phases (Python-side orchestration in run_scenarios)¶
1. refresh_item_indirect_stats → UPDATE master.item demand stats (non-fatal)
2. _load_sku_records → global master.demand_actuals + route + BOM
+ CH PIPE_indirect_demand WH nodes
→ list[dict] of SKU records (583 here)
3. _load_meio_config → DB parameters → MeioConfig dict
(optimizer_version, use_empirical, etc.)
4. _load_target_groups_by_sku → config_segment membership + is_default
fallback → j_target_groups per SKU
5. _load_repair_params_from_routes → RETURN/REPAIR routes → repair_flow
5b. _build_and_attach_empirical_distributions ← V3 EMPIRICAL BOOTSTRAP
↓ reads master.demand_actuals WHERE received_date IS NOT NULL
↓ per (item, site): sum demand over [date, date + actual_lt_days)
↓ attach as sku["demand_over_lt_distribution"] = {"type":"empirical","samples":[…]}
↓ persist to CH PIPE_uncertainty_distributions
6. build scenario batches → one batch per scenario (JSON-serialised)
7. meio_optimizer_v3.run_optimization_batch → Rust greedy loop (Rayon)
Phase 0: build TargetDictionary
Phase 1: apply_initial_asl (per-SKU buffer floor)
Phase 2: initialize_all_marginal_values
Phase 3: greedy_loop (par_iter over candidates per iteration)
Phase 4: asset availability groups (Erlang-B — not exercised here)
8. _log_achievement_validation → demand-weighted FR breakdown
9. _save_sku_results → CH PIPE_meio_results (drop partition + insert)
_save_group_results → CH PIPE_meio_group_results
V3 empirical bootstrap — the key phase (5b)¶
Function: _build_and_attach_empirical_distributions(schema, skus, pipeline_id, n_min=30)
— meio_runner.py:1770-1951.
Gate: runs only when use_empirical_distributions=true AND
meio_optimizer_version='v3' AND meio_optimizer_v3.pyd is importable
(meio_runner.py:264-275).
Algorithm per (item_id, site_id):
1. Collect all master.demand_actuals rows where received_date IS NOT NULL
(meio_runner.py:1806-1814).
2. Group by (item_id, site_id); skip groups with len < 3.
3. For each row: actual_lt_days = (received_date - date).days.
4. Sum demand qty over [date, date + actual_lt_days) for the same
(item, site) using numpy searchsorted for O(N log N) range sums
(meio_runner.py:1881-1884). Also add indirect demand (CH
PIPE_indirect_demand) in the same date range for WH nodes.
5. The resulting samples array is the empirical demand-over-lead-time
distribution. Compute stats: mean, variance, stddev, skewness, kurtosis,
percentiles p10/p25/p50/p75/p90/p95/p99.
6. Attach to the SKU record as
{"type": "empirical", "samples": [...]} (meio_runner.py:1911-1914).
7. Persist to ClickHouse PIPE_uncertainty_distributions
(dist_type='demand_over_lt', source='demand_actuals',
meio_runner.py:1917-1957).
Shrinkage (E3): when n_samples < empirical_shrinkage_n_min (default 30),
the Rust shrinkage logic (sku.rs:279-298, config.rs:178-179) blends the
empirical distribution with the parametric fitted distribution. This is why
V3-B (n=15) does NOT purely use the empirical dist.
How the optimizer consumes the bootstrap¶
In Rust, SkuRecord.demand_over_lt_distribution: Option<DistributionType>
(MEIO_v3/src/sku.rs:201-202) is consumed by
SkuRecord::effective_distribution(threshold, use_empirical) (sku.rs:279-298).
When use_empirical=true and the empirical dist is present (and n≥shrinkage
threshold), it wins over the parametric fitted distribution. This is consumed
in the fill-rate computation: marginal.rs calls
sku.effective_distribution(...) before fill_rate_for_rop
(distributions.rs:163).
When use_empirical=true but NO empirical dist is attached (e.g. V3-C, no
received_date), Rust logs a warning and falls back to the parametric
fitted/legacy distribution (distributions.rs — the
"DistributionType::resolve — use_empirical=true but no empirical distribution;
falling back to fitted/legacy" warnings seen in the log).
Empirical-mean cycle-stock baseline: additionally, compute_mv_recursive
(marginal.rs:573) overrides the planned-LT cycle-stock baseline with the
empirical distribution mean (dist.empirical_mean(), distributions.rs:123) so
the empirical fill-rate curve is evaluated against a baseline consistent with the
actual replenishment lead time. Without this, the planned-LT baseline
(e.g. 4 wk) against a mean-62 empirical distribution drives marginal value ≈ 0
and rop ≈ 0 (the original V3-A failure mode).
Lead times — where they come from¶
Lead time is the sum of three master.route columns (in days), divided by 7
to convert to weeks:
-- meio_runner.py:1034-1048 (primary_route CTE inside _load_sku_records)
((COALESCE(r.lead_time, 0) + COALESCE(r.transit_time, 0)
+ COALESCE(r.pick_pack_time, 0)) / 7.0) AS leg_lead_time
- Only
BUY/TRANSFER/MAKEroute types set the replenishment lead time (RETURN/REPAIRdo NOT —meio_runner.py:1045). DISTINCT ON (r.item_id, r.site_id)ordered byr.priority ASC→ primary (lowest-priority-number) route (meio_runner.py:1035, 1047).- Only active routes (
r.end_date IS NULL OR r.end_date >= CURRENT_DATE).
Lead-time stddev (V3 Phase 3):
COALESCE(isit.lt_stddev, i.lt_stddev, 0.0) (meio_runner.py:1156-1158) —
prefers per-site master.item_location.lt_stddev, falls back to item-level
master.item.lt_stddev, then 0. This is the lead-time variability input (a
separate Phase 3 feature, not the empirical bootstrap).
Config keys that control V3¶
Stored on scen_meio_scenarios.param_overrides JSON (per-scenario):
- meio_optimizer_version: "v1" | "v2" | "v3" (default "v3")
- use_empirical_distributions: bool (default False) — the master on/off switch
- bootstrap_samples: int (default 1000) — number of bootstrap resamples
- empirical_shrinkage_n_min: int (default 30) — shrinkage threshold
- mc_iterations: int (default 1000) — Monte-Carlo iterations (Phase 4, planned)
- mc_seed: int (default 0)
The test scenario uses: meio_optimizer_version="v3",
use_empirical_distributions=True, bootstrap_samples=200,
mc_iterations=500, mc_seed=42.
Segment targets — the abort guard¶
Every active segment MUST have an explicit fill_rate_target or the run
aborts with a ValueError (meio_runner.py:310-320). The test scenario
provides a catch-all parameter set (config_meio_parameter_set with
is_default=TRUE, fill_rate_target=0.95) which applies to every segment via
seg_params[None] → _apply_target_overrides (meio_runner.py:2135-2140).
This avoids the abort without configuring 34 individual segments.
Environment facts¶
| Item | Value |
|---|---|
| Tenant DB | thebicycle, schema scenario |
| ClickHouse DB | thebicycle |
planning_today (global, pipeline_id=-1) |
2026-06-18 (Thu) |
planning_monday (= archive_date) |
2026-06-15 |
| BUY route type | id = 1 (name='External Purchase', planning_type='BUY') |
| V3 Rust extension | meio_optimizer_v3.pyd — present and importable |
| Existing MEIO scenarios | 1–12 (Base, Budget, High SL, Europe, etc.) |
| Existing segments | 34 (is_default: Default=331, All=3663) |
| Demand type ids | 1 Direct |
| Reserved test id range | items 9101101-9101103, sites 9101101-9101103 |
received_date before seed |
0 rows in master.demand_actuals (none in the catalogue) |
master.item_location.lt_stddev |
absent before seed — added idempotently by the script |
interact_io_overrides.pipeline_id |
absent before seed — added idempotently by the script |
PIPE_uncertainty_distributions (CH) |
absent before seed — created idempotently by the script |
| API server | localhost:8002 (up) |
| Frontend | localhost:5173 (up) |
| Run log | docs/meio_v3_run_2026-06-23.log |
Week alignment — all seeded demand dates land on Mondays (the script asserts every date's weekday == Monday). The bootstrap's demand-during-LT windows are therefore whole-week multiples, making the empirical samples exact multiples of the weekly qty.
The three test scenarios¶
V3-A MEIOV3A — rich empirical (n=40, no shrinkage)¶
- Item 9101101 @ site 9101101 (1:1).
- 60 weeks of history (2025-04-28 → 2026-06-15), constant 12 units/week.
- First 40 weeks carry a
received_date=date + 28 + (w % 4) × 7days. → actual lead time cycles through 28/35/42/49 days (4/5/6/7 weeks). → demand-during-LT samples = 12 × {4,5,6,7} = {48, 60, 72, 84} units. - BUY route:
lead_time=14, transit_time=13, pick_pack_time=1→ 28 days (4 wk). - Expected bootstrap: n=40, mean≈61.8, p50=48, p95=84, no shrinkage (n≥30).
V3-B MEIOV3B — sparse empirical (n=15, shrinkage)¶
- Item 9101102 @ site 9101102.
- 25 weeks of history (2025-12-29 → 2026-06-15), constant 8 units/week.
- First 15 weeks carry a
received_date=date + 14 + (w % 4) × 7days. → actual lead time cycles through 14/21/28/35 days (2/3/4/5 weeks). → demand-during-LT samples = 8 × {2,3,4,5} = {16, 24, 32, 40} units. - BUY route:
lead_time=7, transit_time=6, pick_pack_time=1→ 14 days (2 wk). - Expected bootstrap: n=15, mean≈24.5, p50=16, p95=40, shrinkage active (n<30 → Rust E3 blends empirical + parametric).
V3-C MEIOV3C — no empirical (parametric fallback)¶
- Item 9101103 @ site 9101103.
- 47 weeks of history (2025-07-28 → 2026-06-15), varied demand:
qty = 20 + (w % 5 - 2) × 4→ cycles {12, 16, 20, 24, 28}. - No
received_dateon any row → the bootstrap gate skips this SKU. - BUY route:
lead_time=17, transit_time=17, pick_pack_time=1→ 35 days (5 wk). - Expected: NO empirical distribution; V3 falls back to the parametric normal (dmd_stddev from demand_actuals). Result is identical to V2.
Series reference tables¶
All URL uids are numeric (item_id_site_id).
Test SKUs (3 series)¶
| Case | URL uid | Item ID | Item xuid | Item name | Site ID | Site xuid | Weekly qty | LT (wk) | received_date |
|---|---|---|---|---|---|---|---|---|---|
| V3-A | 9101101_9101101 |
9101101 | MEIOV3A | MEIO V3 Test A (rich empirical) | 9101101 | MEIOV3A_SITE | 12.0 | 4 | 40 of 60 rows |
| V3-B | 9101102_9101102 |
9101102 | MEIOV3B | MEIO V3 Test B (sparse empirical) | 9101102 | MEIOV3B_SITE | 8.0 | 2 | 15 of 25 rows |
| V3-C | 9101103_9101103 |
9101103 | MEIOV3C | MEIO V3 Test C (no empirical) | 9101103 | MEIOV3C_SITE | 12-28 (varied) | 5 | 0 of 47 rows |
Routes (lead-time source)¶
| Case | Route type | item_id | site_id | lead_time | transit_time | pick_pack_time | Total LT (days) | LT (weeks) |
|---|---|---|---|---|---|---|---|---|
| V3-A | BUY (id=1) | 9101101 | 9101101 | 14 | 13 | 1 | 28 | 4.0 |
| V3-B | BUY (id=1) | 9101102 | 9101102 | 7 | 6 | 1 | 14 | 2.0 |
| V3-C | BUY (id=1) | 9101103 | 9101103 | 17 | 17 | 1 | 35 | 5.0 |
Item economics¶
| Item ID | unit_cost | cost | price | lt_stddev (item-level) | holding_rate |
|---|---|---|---|---|---|
| 9101101 | 100.0 | 80.0 | 120.0 | 3.0 | 0.20 |
| 9101102 | 60.0 | 48.0 | 72.0 | 2.0 | 0.20 |
| 9101103 | 150.0 | 120.0 | 180.0 | 4.0 | 0.20 |
Seeded data¶
PostgreSQL¶
| Table | Rows | Notes |
|---|---|---|
master.item |
3 | items 9101101-9101103 (xuid MEIOV3*, unit_cost, cost, price, lt_stddev) |
master.location |
3 | sites 9101101-9101103 (xuid MEIOV3*_SITE) |
master.item_location |
3 | unit_cost + holding_rate=0.20 + lt_stddev per site |
master.route |
3 | BUY routes (lead_time + transit_time + pick_pack_time = LT days) |
master.demand_actuals |
132 | 60 (A) + 25 (B) + 47 (C) rows; 55 with received_date (A=40, B=15) |
scenario.scen_meio_scenarios |
1 | "V3 Empirical Test" (is_base=FALSE, param_overrides with V3 keys) |
scenario.config_meio_parameter_set |
1 | catch-all (is_default=TRUE, fill_rate_target=0.95, max_budget=10M) |
scenario.config_scenario_pipeline |
1 | "MEIO V3 Test Lab" (pipeline_id=110, meio_scenario_id=24) |
All master.demand_actuals rows tagged with source='meio_v3_test_lab' for cleanup.
ClickHouse¶
| Table | Rows | Notes |
|---|---|---|
PIPE_uncertainty_distributions |
2 | V3-A (n=40) + V3-B (n=15) bootstrap output; V3-C has none |
PIPE_meio_results |
583 | All catalogue SKUs (global universe); 3 test SKUs filterable by id |
PIPE_meio_group_results |
34 | One row per segment (Default, A/B/C items, Fast/Slow movers, etc.) |
All CH rows carry pipeline_id=110, scenario_id=24, archive_date=2026-06-15.
Schema migrations applied idempotently¶
The seed script's ensure_schema() adds three columns/tables that this tenant
was missing (they are part of v3_phase0_pg.sql / v3_phase0_ch.sql but had
not been applied):
| Object | Change |
|---|---|
scenario.interact_io_overrides |
ADD COLUMN pipeline_id BIGINT |
master.item_location |
ADD COLUMN lt_stddev DOUBLE PRECISION |
CH PIPE_uncertainty_distributions |
CREATE TABLE IF NOT EXISTS (full DDL) |
Phase-by-phase verification plan¶
This is the step-by-step plan to review the results of each process, with screens and DB SQLs to validate the overall correctness of the algorithm.
Phase 0 — Inputs: lead times¶
Question: Are lead times available? Where do they come from?
Answer: Lead times come from master.route — the sum of lead_time +
transit_time + pick_pack_time (in days), divided by 7 for weeks. Only
BUY/TRANSFER/MAKE routes set the replenishment lead time.
SQL (PostgreSQL):
-- Verify the route-derived lead time for the 3 test SKUs
SELECT r.item_id, r.site_id, i.xuid,
r.lead_time, r.transit_time, r.pick_pack_time,
(r.lead_time + r.transit_time + r.pick_pack_time) AS lt_days,
ROUND((r.lead_time + r.transit_time + r.pick_pack_time) / 7.0, 4) AS lt_weeks
FROM master.route r
JOIN master.item i ON i.id = r.item_id
WHERE r.item_id IN (9101101, 9101102, 9101103)
ORDER BY r.item_id;
UI: Navigate to http://localhost:5173/pipelines → select MEIO V3 Test Lab
→ MEIO tab → open the scenario editor → the lead-time column in the results
table shows leg_lead_time (weeks).
Phase 1 — Inputs: demand + received_date¶
Question: Is there enough demand history with received_date to build
empirical distributions?
SQL (PostgreSQL):
-- Demand volume + received_date coverage for the 3 test SKUs
SELECT item_id,
count(*) AS n_rows,
count(received_date) AS n_with_received_date,
min(date) AS first_date,
max(date) AS last_date,
round(avg(qty)::numeric, 2) AS avg_qty
FROM master.demand_actuals
WHERE source = 'meio_v3_test_lab'
GROUP BY item_id
ORDER BY item_id;
-- Inspect the actual received_date pattern for V3-A (first 8 rows)
SELECT date, qty, received_date,
(received_date - date) AS actual_lt_days,
((received_date - date) / 7.0) AS actual_lt_weeks
FROM master.demand_actuals
WHERE item_id = 9101101 AND received_date IS NOT NULL
ORDER BY date
LIMIT 8;
Phase 2 — Bootstrap output: empirical distributions¶
Question: What is the result of the bootstrap process? How is it stored?
Answer: The bootstrap builds a per-SKU empirical demand-over-LT
distribution (sorted samples + stats) and stores it in CH
PIPE_uncertainty_distributions with dist_type='demand_over_lt'.
SQL (ClickHouse):
-- Bootstrap output for the 3 test SKUs
SELECT item_id, site_id, dist_type, n_samples,
round(mean, 2) AS mean, round(stddev, 2) AS std,
round(skewness, 2) AS skew, round(kurtosis, 2) AS kurt,
p10, p25, p50, p75, p90, p95, p99, source
FROM PIPE_uncertainty_distributions
WHERE pipeline_id = 110
AND item_id IN (9101101, 9101102, 9101103)
ORDER BY item_id;
-- Inspect the raw bootstrap samples for V3-A (JSON array in sample_blob)
SELECT item_id, n_samples,
sample_blob,
length(sample_blob) AS blob_len
FROM PIPE_uncertainty_distributions
WHERE pipeline_id = 110 AND item_id = 9101101;
sample_blob is a JSON array like [48, 48, ..., 60, 60, ...,
72, 72, ..., 84, 84] (40 values, each a multiple of 12).
API:
Returns allPIPE_uncertainty_distributions rows for the pipeline.
Phase 3 — How the bootstrap is consumed by the optimizer¶
Question: How is the bootstrap consumed by the MEIO algorithm?
Answer: The empirical dist is attached to the Python SKU dict as
demand_over_lt_distribution = {"type": "empirical", "samples": [...]}, then
JSON-serialised and passed to meio_optimizer_v3.run_optimization_batch. In
Rust, SkuRecord::effective_distribution() picks the empirical dist when
use_empirical=true and n≥shrinkage threshold. When n<30 (V3-B), Rust E3
shrinkage blends it with the parametric. When no empirical dist exists (V3-C),
Rust falls back to the parametric and logs a warning.
Evidence in the run log (docs/meio_v3_run_2026-06-23.log):
# Bootstrap attached 2 empirical dists (A + B):
INFO meio_runner: Attached empirical distributions to 2 / 583 SKUs
# V3-C and the other 581 catalogue SKUs fall back (expected):
WARNING meio_optimizer_v3.distributions: DistributionType::resolve —
use_empirical=true but no empirical distribution; falling back to fitted/legacy
Verification: the "falling back" warnings should appear for every SKU
WITHOUT an empirical dist (581 of 583). The 2 test SKUs (A, B) should NOT
trigger this warning (they have empirical dists). Confirm:
# Count fallback warnings (should be 581 — every SKU without an empirical dist)
(Get-Content docs\meio_v3_run_2026-06-23.log | Select-String "falling back to fitted").Count
Phase 4 — Optimizer results: safety stock¶
Question: What safety stock (committed_buffer) and fill rate did V3 compute?
SQL (ClickHouse):
-- V3 results for the 3 test SKUs
SELECT item_id, site_id,
round(committed_buffer, 2) AS buffer,
round(fill_rate, 4) AS fill_rate,
round(marginal_value, 5) AS marg_val,
round(inventory_value, 2) AS inv_value,
leg_lead_time, is_direct_demand
FROM PIPE_meio_results
WHERE pipeline_id = 110
AND scenario_id = 24
AND item_id IN (9101101, 9101102, 9101103)
ORDER BY item_id;
API:
Returns{sku_results: [...], group_results: [...]} with full item/site names.
Phase 5 — Group-level fill-rate achievement¶
Question: Did the optimizer achieve the 95% fill-rate target per segment?
SQL (ClickHouse):
-- Group-level achievement (all 34 segments)
SELECT group_name, segment_id,
round(achieved_fill_rate, 4) AS achieved,
round(fill_rate_target, 3) AS target,
completed, completion_reason
FROM PIPE_meio_group_results
WHERE pipeline_id = 110 AND scenario_id = 24
ORDER BY segment_id;
weighted_fill_rate=0.9555 (from the run summary). Some WH-only segments
(Central/Country warehouses) show 0.0 with completion_reason='auto' (no
direct-demand SKUs in those segments).
Phase 6 — Multithreading verification¶
Question: How can I make sure it did multithread?
Answer: See Multithreading verification below.
Actual verified results¶
These are the actual outputs from the seeded pipeline (pipeline_id=110,
scenario_id=24), captured by the seed script's verify() function.
Lead times (Phase 0)¶
Demand + received_date (Phase 1)¶
item_id rows w/rec avg_qty first→last
9101101 60 40 12.00 2025-04-28→2026-06-15
9101102 25 15 8.00 2025-12-29→2026-06-15
9101103 47 0 19.74 2025-07-28→2026-06-15
Bootstrap output (Phase 2)¶
item n_samp mean std skew p50 p95 source
9101101 40 61.80 16.87 -0.64 48.00 84.00 demand_actuals
9101102 15 24.53 9.45 0.11 16.00 40.00 demand_actuals
→ 9101101: n=40 (exp 40) [OK] mean=61.80 (range 50-70) [OK]
→ 9101102: n=15 (exp 15) [OK] mean=24.53 (range 18-32) [OK] shrinkage active
→ 9101103: correctly NO empirical dist (parametric fallback)
MEIO results (Phase 4)¶
item buffer fill_rt marg_val inv_val lt_wk direct
9101101 84.00 0.800 0.0000 8400.00 4.00 1
9101102 40.00 0.867 0.0000 2400.00 2.00 1
9101103 115.00 0.941 0.0051 17250.00 5.00 1
Group results (Phase 5)¶
segment achieved target done reason
Default 0.9500 0.950 1 fill_rate
A items 0.9502 0.950 1 fill_rate
B items 0.9506 0.950 1 fill_rate
C items 0.9506 0.950 1 fill_rate
Fast movers 0.9501 0.950 1 fill_rate
Slow / intermittent 0.0000 0.950 1 auto
High-value items (>500) 0.9499 0.950 0
Mid-value items (100-500) 0.9501 0.950 1 fill_rate
Low-value items (<100) 0.9508 0.950 1 fill_rate
Central warehouses 0.0000 0.950 1 auto
Country warehouses 0.0000 0.950 1 auto
Dealer network 0.9502 0.950 1 fill_rate
... (22 more segments)
Run summary: weighted_fill_rate=0.9555, sku_count=583, group_count=34, iterations=14
Multithreading (Phase 6)¶
V3 dispatch line : FOUND → Using MEIO optimizer V3 for 1 scenario(s)
(use_empirical=True, bootstrap=200, mc_iter=500)
batch submit : FOUND → Submitting 1 batch(es) to meio_optimizer_v3
batch par_iter : FOUND
greedy iterations : 14 logged (each iter evaluates batch_size candidates via par_iter)
e.g. iteration 0 selected 24 candidates (batch_size=24)
Rust finish : FOUND → Rust optimizer finished in 4.27s
thread-pool cap : FOUND → [AUTOSCALE:meio] threads_cap=24
bootstrap timing : FOUND → build_demand_over_lt=7.353s
timed sections : 23 (>> END ... -- Xs)
UI validation¶
1. MEIO scenario + results¶
Navigate to http://localhost:5173/pipelines → select MEIO V3 Test Lab
(pipeline_id=110) in the left toolbar → click the MEIO tab.
| What to look for | Where |
|---|---|
| Scenario "V3 Empirical Test" with a ⚙ V3 +MC badge | Scenario list grid (MeioScenarios.jsx:2632) |
meio_optimizer_version = v3, use_empirical_distributions = true |
Scenario editor → V3 fields (MeioScenarios.jsx:2253-2334) |
bootstrap_samples = 200, mc_iterations = 500, mc_seed = 42 |
Scenario editor → V3 fields |
SKU results table: committed_buffer, fill_rate, marginal_value, inventory_value, leg_lead_time for items 9101101-9101103 |
Results panel (MeioScenarios.jsx:1230-1539) |
| Group summary strip: 34 segments, most ≈0.950 achieved | Results panel → group summary |
2. Fill-rate heatmap¶
In the MEIO tab, open the Fill-Rate Heatmap section
(MeioScenarios.jsx:1634). Filter by pipeline_id=110. The test SKUs should
appear with their achieved fill rates (A=0.80, B=0.87, C=0.94).
3. Lead-time actuals comparison¶
Navigate to the IO tab of a test series (e.g.
http://localhost:5173/series/9101101_9101101) → the lead-time chart
(DetailIOTab.jsx:814) compares MEIO leg_lead_time + wait_time vs observed
received_date - date. For V3-A, the observed actual LT cycles through
28/35/42/49 days while the MEIO leg_lead_time is 28 days (4 wk).
4. MEIO diagnostic¶
Returns per-series diagnostic: fill rate, safety stock, lead-time, flags. Filter for item_id IN (9101101, 9101102, 9101103) to see the 3 test SKUs.5. Run log (multithreading evidence)¶
Open docs/meio_v3_run_2026-06-23.log in any editor. Key lines to grep:
- Using MEIO optimizer V3 — V3 dispatch confirmation
- Submitting 1 batch(es) to meio_optimizer_v3 — batch submission
- iteration \d+ selected \d+ candidates (batch_size=\d+) — greedy loop (14 iterations)
- Rust optimizer finished in 4.27s — total Rust time
- build_demand_over_lt -- 7.353s — bootstrap phase timing
- [AUTOSCALE:meio] started — thread-pool monitor (threads_cap=24)
How to drive the test¶
Full seed + run + verify (default)¶
cd C:\allDev\ForecastAI2026.01\files
$env:PYTHONIOENCODING="utf-8"
python scripts\seed_v3_meio_isolated.py
docs/meio_v3_run_<date>.log.
Seed only (no run)¶
Seeds the data + pipeline but does not run MEIO. Run MEIO later with: (The script prints the exact command with the current scenario/pipeline ids.)Run with V2 baseline for contrast¶
Also runs a V2 (parametric, no empirical) scenario on the same pipeline so you can compare V3-vs-V2 safety stock side-by-side inPIPE_meio_results (they
have different scenario_ids). See V3 vs V2 contrast.
Cap the thread pool¶
Setsparallel_workers=4 (Rayon thread pool cap). Default 0 = auto-detect all
CPUs. Useful for verifying multithreading with a known thread count.
Cleanup only¶
Removes all test artifacts (PG + CH) and exits. See Cleanup.Run via the API (instead of in-process)¶
# After seeding with --no-run:
curl -X POST "http://localhost:8002/api/meio/scenarios/<sid>/run?pipeline_id=<pid>"
{"status":"started","scenario_id":<sid>,"pid":<os_pid>} (HTTP 202).
The API spawns python -m meio_runner --scenario-ids <sid> --pipeline-id <pid>
as a detached subprocess. Status is visible in the UI Process Runner.
Idempotency: every artifact is tagged (MEIO V3 Test Lab pipeline,
MEIOV3* xuids, 9101xxx ids, meio_v3_test_lab demand source) and removed
before re-seeding. Consecutive runs get incrementing pipeline_ids (verified
110 → 111 → ...). The schema migrations (ensure_schema) are also idempotent.
V3 vs V2 contrast¶
Run python scripts\seed_v3_meio_isolated.py --v2 to produce a V2 baseline
scenario (parametric, use_empirical_distributions=false) on the same
pipeline. The two scenarios coexist in PIPE_meio_results with different
scenario_ids.
SQL (ClickHouse) — side-by-side:
SELECT
v3.item_id,
round(v3.committed_buffer, 2) AS v3_buffer,
round(v3.fill_rate, 4) AS v3_fill_rate,
round(v2.committed_buffer, 2) AS v2_buffer,
round(v2.fill_rate, 4) AS v2_fill_rate,
round(v3.committed_buffer - v2.committed_buffer, 2) AS buffer_diff
FROM PIPE_meio_results v3
JOIN PIPE_meio_results v2
ON v2.item_id = v3.item_id AND v2.site_id = v3.site_id
AND v2.pipeline_id = v3.pipeline_id
WHERE v3.pipeline_id = 110
AND v3.scenario_id = <v3_sid> -- V3 Empirical Test
AND v2.scenario_id = <v2_sid> -- V3 Empirical Test (V2 baseline)
AND v3.item_id IN (9101101, 9101102, 9101103)
ORDER BY v3.item_id;
Observed contrast (from a prior run):
| Item | V3 buffer | V3 fill_rate | V2 buffer | V2 fill_rate | Δ buffer | Why |
|---|---|---|---|---|---|---|
| 9101101 (A, empirical) | 84.00 | 0.800 | 60.00 | 0.948 | +24.00 | Empirical dist has a heavier tail (p95=84) → V3 holds more buffer, but the empirical fill-rate at that buffer is lower (the empirical CDF is steeper) |
| 9101102 (B, shrinkage) | 40.00 | 0.867 | 23.00 | 0.942 | +17.00 | Shrinkage blends empirical (skewed right, p95=40) with parametric → more buffer than pure parametric |
| 9101103 (C, no empirical) | 115.00 | 0.941 | 115.00 | 0.941 | 0.00 | Identical — no received_date → V3 falls back to parametric = V2 |
Key insight: V3-C proves the fallback path is correct (V3 = V2 when no empirical data). V3-A and V3-B show the empirical distribution produces different (typically higher) safety stock than the parametric normal, reflecting the actual observed demand-during-lead-time variability rather than a Normal approximation.
Multithreading verification¶
How MEIO V3 parallelises¶
MEIO V3 uses Rayon (Rust data-parallelism) at two levels:
-
Across-scenario —
run_optimization_batch(MEIO_v3/src/lib.rs:218-224):batches.par_iter().map(|b| run_optimization_inner(...)). Each scenario is one batch; scenarios run in parallel. (Here there is 1 scenario, so this level has 1 batch — thepar_iterstill runs but with 1 element.) -
Within-optimizer —
Optimizer::greedy_loop(MEIO_v3/src/optimizer.rs:326-334): each greedy iteration selectsbatch_sizecandidates, thencandidates.par_iter().map(|key| compute_marginal_value(...))evaluates them in parallel. Results are committed serially.
Thread pool configuration¶
MeioConfig.parallel_workers(config.rs:98-99) — 0 = auto-detect viarayon::current_num_threads().- Pool built globally in
run_optimization_inner(lib.rs:118-123):rayon::ThreadPoolBuilder::new().num_threads(cfg.parallel_workers).build_global(). - Configured via the
meio_parallel_workersDB parameter or the--workersCLI arg /parallel_workersAPI arg.
How to verify it ran in parallel¶
1. Run log evidence (docs/meio_v3_run_2026-06-23.log):
| Log line | Meaning |
|---|---|
Using MEIO optimizer V3 for 1 scenario(s) |
V3 dispatch confirmed |
Submitting 1 batch(es) to meio_optimizer_v3 |
Batch submitted to Rust |
running 1 batches in parallel (Rust, lib.rs:216) |
Across-scenario par_iter (1 batch here) |
iteration 0 selected 24 candidates (batch_size=24) |
Within-iter par_iter — 24 candidates evaluated in parallel per iteration |
Rust optimizer finished in 4.27s |
Total Rust wall-time |
[AUTOSCALE:meio] started ... threads_cap=24 |
CPU monitor confirms thread pool cap |
Grep the log:
# Greedy iterations (14 expected — each uses par_iter over batch_size candidates)
(Get-Content docs\meio_v3_run_2026-06-23.log |
Select-String "iteration \d+ selected \d+ candidates").Count
# Across-scenario par_iter
Get-Content docs\meio_v3_run_2026-06-23.log | Select-String "batches in parallel"
# Thread pool monitor
Get-Content docs\meio_v3_run_2026-06-23.log | Select-String "AUTOSCALE:meio"
2. CPU usage during the run: the CpuAutoscaler(mode="monitor")
(meio_runner.py:357-361) records CPU usage. During the Rust dispatch, you
should see CPU usage spike across multiple cores (visible in Task Manager or
the autoscaler's logged samples).
3. Force a known thread count:
Then grep the log forthreads_cap=4 — the autoscaler line confirms the pool
was capped at 4. The greedy iterations should still run (batch_size may be
smaller).
4. Timed sections: the _timed context manager (meio_runner.py:45-54)
logs >> START / >> END ... -- Xs for every major phase. The
meio_optimizer_v3.run_optimization_batch timing (4.27s here) vs the
serial-bootstrap timing (build_demand_over_lt=7.353s) shows the Rust phase is
fast despite 583 SKUs × 14 iterations — only possible with parallelism.
Cleanup¶
The --clean flag removes all test artifacts. The deletion order is FK-safe
(PG first, then CH):
PostgreSQL (FK-safe order)¶
config_scenario_pipeline(WHERE name = 'MEIO V3 Test Lab')scen_meio_scenarios(WHERE name = 'V3 Empirical Test') — CASCADE deletesconfig_meio_parameter_set+config_meio_parameter_set_segmentmaster.demand_actuals(WHERE source = 'meio_v3_test_lab')master.route(WHERE item_id IN 9101xxx)master.item_location(WHERE item_id IN 9101xxx)master.item(WHERE id IN 9101xxx)master.location(WHERE id IN 9101xxx)
ClickHouse (partition-scoped)¶
ALTER TABLE PIPE_meio_results DELETE WHERE pipeline_id = 110;
ALTER TABLE PIPE_meio_group_results DELETE WHERE pipeline_id = 110;
ALTER TABLE PIPE_uncertainty_distributions DELETE WHERE pipeline_id = 110;
Manual cleanup SQL¶
-- PostgreSQL
DELETE FROM scenario.config_scenario_pipeline WHERE name = 'MEIO V3 Test Lab';
DELETE FROM scenario.scen_meio_scenarios WHERE name LIKE 'V3 Empirical Test%';
DELETE FROM master.demand_actuals WHERE source = 'meio_v3_test_lab';
DELETE FROM master.route WHERE item_id IN (9101101, 9101102, 9101103);
DELETE FROM master.item_location WHERE item_id IN (9101101, 9101102, 9101103);
DELETE FROM master.item WHERE id IN (9101101, 9101102, 9101103);
DELETE FROM master.location WHERE id IN (9101101, 9101102, 9101103);
Note: the three schema migrations added by ensure_schema()
(interact_io_overrides.pipeline_id, master.item_location.lt_stddev,
PIPE_uncertainty_distributions) are NOT removed by cleanup — they are
idempotent infrastructure additions needed by the V3 algorithm. They are safe
to leave in place.
Design decisions (Q&A)¶
| Question | Decision | Rationale |
|---|---|---|
| Why 1:1 item-to-site mapping? | Item N lives at site N (9101101@9101101, etc.) | Simplifies the test — no multi-site aggregation, no transfer routes, no BOM. Each SKU is a single-echelon BUY replenishment. |
| Why constant weekly demand for A & B? | 12 (A) and 8 (B) units/week | Makes the demand-during-LT samples exact multiples of the weekly qty (12×{4,5,6,7} or 8×{2,3,4,5}) — trivially hand-verifiable. |
| Why varied demand for C? | qty = 20 + (w%5-2)*4 → |
C has no received_date so the empirical path is skipped; the parametric fallback needs non-zero dmd_stddev to produce a non-trivial safety stock. Constant demand would give stddev=0 → zero safety stock. |
Why jitter received_date in a 4-week cycle? |
received_date = date + base_lt + (w%4)*7 |
Creates 4 distinct demand-during-LT window sizes, so the empirical distribution has 4 distinct sample values (not all identical). A 4-week cycle over 40 weeks gives 10 of each value. |
| Why a catch-all parameter set (is_default=TRUE)? | fill_rate_target=0.95 applies to ALL 34 segments |
Avoids the "fill_rate_target not configured" abort (meio_runner.py:310-320) without configuring 34 individual segment parameter sets. The catch-all is read as seg_params[None] and applied as the baseline in _apply_target_overrides. |
Why bootstrap_samples=200 (not the default 1000)? |
Smaller for speed | The bootstrap resample count affects the Rust-side empirical CDF resolution; 200 is enough for a test and keeps the run fast (~15s total). |
| Why in-process run (not the API subprocess)? | run_scenarios(...) called directly |
Captures the full Rust/Rayon log stream in a file (docs/meio_v3_run_<date>.log) for multithreading verification. The API subprocess also works but its log goes to the process_log table. |
| Why not isolate to only the 3 test SKUs? | MEIO loads the GLOBAL demand_actuals universe | _load_sku_records has no pipeline filter on master.demand_actuals (only EOQ + indirect-demand WH injection is pipeline-scoped). The run optimises all 583 catalogue SKUs; verification filters by the 9101xxx test ids. The bootstrap, however, is isolated: only the 3 test SKUs have received_date, so only they get empirical dists. |
| Why add schema migrations in the seed script? | This tenant was missing V3 infrastructure | interact_io_overrides.pipeline_id, master.item_location.lt_stddev, and PIPE_uncertainty_distributions are part of v3_phase0_pg.sql/v3_phase0_ch.sql but had not been applied. The script adds them idempotently so the V3 runner doesn't crash. |
Risks & guards¶
- Global SKU universe: MEIO loads ALL 583 catalogue SKUs (not just the 3
test SKUs). The run summary (
sku_count=583) andPIPE_meio_resultsinclude the whole catalogue. Always filter verification byitem_id IN (9101101, 9101102, 9101103)orpipeline_id=110. - Bootstrap isolation: only the 3 test SKUs have
received_dateinmaster.demand_actuals(the catalogue had 0 before seeding). The bootstrap therefore builds empirical dists ONLY for the test SKUs — the other 581 SKUs fall back to parametric (logged as"falling back to fitted/legacy"warnings, which is expected, not an error). - Planning date rule:
archive_date = planning_monday(pipeline_id) = 2026-06-15(fromscenario.planning_datepipeline_id=-1). All CH writes use this, neverdate.today(). If the planning date changes, re-run the seed script to refresharchive_date. - Week alignment: every seeded demand date is a Monday (the script asserts this). The bootstrap's demand-during-LT windows are whole-week multiples. If you manually edit demand dates, ensure they stay on Mondays.
- Schema migrations are permanent: the three columns/tables added by
ensure_schema()are NOT removed by--clean. They are safe infrastructure needed by V3. If you want to remove them, do so manually (not recommended). - Fill-rate target abort: if you delete the catch-all parameter set
(
config_meio_parameter_setwithis_default=TRUE), the next MEIO run will abort with "fill_rate_target is not configured for segment(s): ...". Always keep the catch-all. - V2 baseline cleanup: the
--v2flag creates an extra MEIO scenario ("V3 Empirical Test (V2 baseline)") that is NOT removed by--clean(the clean script only deletes scenarios named exactly "V3 Empirical Test"). Remove it manually:DELETE FROM scenario.scen_meio_scenarios WHERE name = 'V3 Empirical Test (V2 baseline)';. - Bug fix applied: the seed script run exposed a pre-existing bug in
_log_achievement_validation(meio_runner.py:2436) whereheapqcompared dicts on equaldirect_demand_rateties →TypeError. Fixed by adding an integer tiebreaker to the heap entries. Also fixed a bootstrap persist bug wherearchive_datewas passed as.isoformat()(string) instead of adateobject (meio_runner.py:1920).
Out of scope¶
- Monte-Carlo simulation (Phase 4):
mc_iterations=500andmc_seed=42are set in the scenario config, but the MC engine is a planned Phase 4 feature (monte_carlo.rsscaffold) — it does not yet affect safety stock. Themc_iter=500value is logged but has no observable effect on results. - Forecast-error distributions (Phase 2):
dist_type='forecast_error'is a planned Phase 2 feature — not seeded here. - Lead-time variability distributions (Phase 3):
master.item_location.lt_stddevis seeded (3.0/2.0/4.0 days) and read byCOALESCE(isit.lt_stddev, i.lt_stddev, 0), but the dedicatedPIPE_lead_time_statstable and the LT-distribution bootstrap are a planned Phase 3 feature — not exercised here. - Multi-echelon networks: the test uses single-echelon BUY routes only
(no transfer routes, no BOM, no WH aggregation). The indirect-demand WH
injection path (
_load_sku_recordsUNION ofPIPE_indirect_demandnodes) is not exercised by the 3 test SKUs (they have no upstream/downstream routes). - Reverse logistics (RETURN/REPAIR routes): not seeded — the 3 test SKUs
have no RETURN/REPAIR routes, so
repair_flow=Nonefor all. - Asset availability groups (Erlang-B): not seeded —
asset_availability_groupsis empty in the config, so Phase 4 (Erlang-B) is skipped. - RL policy layer (Phase 15):
rl_episode_mode=False— not exercised.