Skip to content

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

  1. Goal & design rationale
  2. MEIO V3 mechanics recap
  3. Environment facts
  4. The three test scenarios
  5. Series reference tables
  6. Seeded data
  7. Phase-by-phase verification plan
  8. Actual verified results
  9. UI validation
  10. How to drive the test
  11. V3 vs V2 contrast
  12. Multithreading verification
  13. Cleanup
  14. Design decisions (Q&A)
  15. Risks & guards
  16. 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 / MAKE route types set the replenishment lead time (RETURN/REPAIR do NOT — meio_runner.py:1045).
  • DISTINCT ON (r.item_id, r.site_id) ordered by r.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) × 7 days. → 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=128 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) × 7 days. → 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=114 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_date on any row → the bootstrap gate skips this SKU.
  • BUY route: lead_time=17, transit_time=17, pick_pack_time=135 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;
Expected: A=28d/4wk, B=14d/2wk, C=35d/5wk.

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;
Expected: A=60 rows/40 w-rec, B=25 rows/15 w-rec, C=47 rows/0 w-rec.

-- 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;
Expected: actual_lt_days cycles through 28/35/42/49 (the jitter pattern).

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;
Expected: 2 rows — A (n=40, mean≈61.8, p50=48, p95=84) and B (n=15, mean≈24.5, p50=16, p95=40). V3-C has NO row (no received_date).

-- 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;
Expected: sample_blob is a JSON array like [48, 48, ..., 60, 60, ..., 72, 72, ..., 84, 84] (40 values, each a multiple of 12).

API:

GET http://localhost:8002/api/meio/distributions?pipeline_id=110
Returns all PIPE_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;
Expected (V3 empirical): | Item | buffer | fill_rate | inv_value | LT (wk) | |------|--------|-----------|-----------|---------| | 9101101 (A) | 84.00 | 0.800 | 8400.00 | 4.0 | | 9101102 (B) | 40.00 | 0.867 | 2400.00 | 2.0 | | 9101103 (C) | 115.00 | 0.941 | 17250.00 | 5.0 |

API:

GET http://localhost:8002/api/meio/scenarios/24/results?pipeline_id=110
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;
Expected: most segments achieve ≈0.950 (the catch-all target); 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)

  item        lt_d   lt_wk  (lead+transit+pick)
  MEIOV3A       28  4.0000
  MEIOV3B       14  2.0000
  MEIOV3C       35  5.0000

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

GET http://localhost:8002/api/meio/diagnostic?pipeline_id=110
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
This cleans prior artifacts, seeds the 3 SKUs, creates the pipeline + MEIO scenario, runs MEIO V3 in-process, and prints the verification table. Takes ~15s. The run log is written to docs/meio_v3_run_<date>.log.

Seed only (no run)

python scripts\seed_v3_meio_isolated.py --no-run
Seeds the data + pipeline but does not run MEIO. Run MEIO later with:
python -m meio_runner --scenario-ids <sid> --pipeline-id <pid>
(The script prints the exact command with the current scenario/pipeline ids.)

Run with V2 baseline for contrast

python scripts\seed_v3_meio_isolated.py --v2
Also runs a V2 (parametric, no empirical) scenario on the same pipeline so you can compare V3-vs-V2 safety stock side-by-side in PIPE_meio_results (they have different scenario_ids). See V3 vs V2 contrast.

Cap the thread pool

python scripts\seed_v3_meio_isolated.py --workers 4
Sets parallel_workers=4 (Rayon thread pool cap). Default 0 = auto-detect all CPUs. Useful for verifying multithreading with a known thread count.

Cleanup only

python scripts\seed_v3_meio_isolated.py --clean
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>"
Returns {"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:

  1. Across-scenariorun_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 — the par_iter still runs but with 1 element.)

  2. Within-optimizerOptimizer::greedy_loop (MEIO_v3/src/optimizer.rs:326-334): each greedy iteration selects batch_size candidates, then candidates.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 via rayon::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_workers DB parameter or the --workers CLI arg / parallel_workers API 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:

python scripts\seed_v3_meio_isolated.py --workers 4
Then grep the log for threads_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)

  1. config_scenario_pipeline (WHERE name = 'MEIO V3 Test Lab')
  2. scen_meio_scenarios (WHERE name = 'V3 Empirical Test') — CASCADE deletes config_meio_parameter_set + config_meio_parameter_set_segment
  3. master.demand_actuals (WHERE source = 'meio_v3_test_lab')
  4. master.route (WHERE item_id IN 9101xxx)
  5. master.item_location (WHERE item_id IN 9101xxx)
  6. master.item (WHERE id IN 9101xxx)
  7. 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) and PIPE_meio_results include the whole catalogue. Always filter verification by item_id IN (9101101, 9101102, 9101103) or pipeline_id=110.
  • Bootstrap isolation: only the 3 test SKUs have received_date in master.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 (from scenario.planning_date pipeline_id=-1). All CH writes use this, never date.today(). If the planning date changes, re-run the seed script to refresh archive_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_set with is_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 --v2 flag 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) where heapq compared dicts on equal direct_demand_rate ties → TypeError. Fixed by adding an integer tiebreaker to the heap entries. Also fixed a bootstrap persist bug where archive_date was passed as .isoformat() (string) instead of a date object (meio_runner.py:1920).

Out of scope

  • Monte-Carlo simulation (Phase 4): mc_iterations=500 and mc_seed=42 are set in the scenario config, but the MC engine is a planned Phase 4 feature (monte_carlo.rs scaffold) — it does not yet affect safety stock. The mc_iter=500 value 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_stddev is seeded (3.0/2.0/4.0 days) and read by COALESCE(isit.lt_stddev, i.lt_stddev, 0), but the dedicated PIPE_lead_time_stats table 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_records UNION of PIPE_indirect_demand nodes) 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=None for all.
  • Asset availability groups (Erlang-B): not seeded — asset_availability_groups is empty in the config, so Phase 4 (Erlang-B) is skipped.
  • RL policy layer (Phase 15): rl_episode_mode=False — not exercised.