MEIO Component-vs-FG Stock Balancing — Isolated Test Guide¶
Date: 2026-06-24
Tenant: thebicycle
Scope: MEIO optimizer balancing safety-stock investment between a component and its finished good (FG) when a master.bill_of_material link couples them — including the kit-wait-time propagation fix that makes the balancing observable.
Script: files/scripts/seed_meio_bom_balance.py
Seeded pipeline: MEIO BOM Balance Lab (pipeline_id varies per run; scenario_id varies)
TOC¶
- Goal & design rationale
- The BOM / kit balancing mechanism
- The kit-wait-time propagation fix
- Environment facts
- The test design
- Series reference tables
- Actual verified results
- How to drive the test
- Verification SQLs
- Code changes
- Risks & guards
- Out of scope
Goal & design rationale¶
The MEIO V3 empirical-bootstrap test (meio_v3_test_guide_2026-06-23.md) exercised
single-echelon BUY replenishment. This test fills the gap flagged there as "out of
scope — multi-echelon networks" by demonstrating how the MEIO optimizer balances
safety-stock investment between a component and its finished good (FG) when
a bill-of-material (BOM) link couples them.
The test creates two identical SKU pairs — the only difference is the BOM link:
| Pair | Site | FG | Component | BOM link | Purpose |
|---|---|---|---|---|---|
| Linked | 9101201 | FG-L (9101201) | COMP-L (9101202) | ✅ FG-L → COMP-L | Demonstrates kit coupling |
| Control | 9101202 | FG-C (9101203) | COMP-C (9101204) | ❌ none | Isolates the BOM effect |
Both pairs have identical demand (20/wk, varied), lead times (FG=2wk, COMP=4wk), and economics (FG unit_cost=200, COMP unit_cost=20). The only variable is the BOM link.
A dedicated segment (MEIO BOM Balance, fill_rate_target=0.98) isolates the 4 test
SKUs so the group gain isn't diluted by the 583-SKU catalogue universe.
The BOM / kit balancing mechanism¶
Topology loading (meio_runner.py:1066-1103)¶
master.bill_of_material rows link a parent (FG/assembly) to child (component) items.
The MEIO SKU loader builds three topology fields from BOM:
| Field | CTE | Meaning |
|---|---|---|
components |
components_cte (group by item_id, site_id) |
Child item_ids this FG assembles from |
kits |
kits_cte (group by child_item_id, child_site_id) |
Parent FG item_ids this component feeds into |
parent_ids |
parents_cte |
Same as kits (simple list) |
For the linked pair's BOM row (item_id=9101201, child_item_id=9101202):
- FG-L (9101201) gets components = [9101202]
- COMP-L (9101202) gets kits = [9101201], parent_ids = [9101201]
Kit probabilistic wait time (marginal.rs — kit_probabilistic_wait_time)¶
kit_probabilistic_wait_time(kit_key, modified_component_id, new_fill_rate, skus):
1. Looks up the FG (kit) SKU.
2. Collects all the FG's components and their current fill rates.
3. Sorts by lead time (ascending).
4. For each component i: computes prob_i = (1 - fr_i) × ∏(fr_j for components j with longer LT).
5. Returns Σ(prob_i × lt_i) — the expected wait time the FG suffers due to component stockouts.
Formula¶
where FR_i is component i's fill rate and LT_i is its lead time.
Why this is an expected value, not a sequential sum¶
Because replenishment orders are placed in parallel (each component has its own
independent supply chain), the actual assembly wait equals the max(LT_i) over the
set of components that are short — not the sum. The Π_{j: LT_j > LT_i} FR_j
exclusion term ensures each stockout scenario is counted exactly once under the
longest short component, matching this parallel-max model:
- Component
i's term fires only when all longer-LT components are available (Π FR_j). If any longer-LT component is also short, the wait is dominated by that longer LT, and the scenario is counted under the longer component instead. - The terms are mutually exclusive: each stockout combination maps to exactly
one term (the longest short component). So the sum of terms is the expected value
of
max(LT_i)over short components, not a sequential sum of lead times.
Worked example (2 components)¶
With LT_A = 2 wk, FR_A = 0.95, LT_B = 4 wk, FR_B = 0.90 (sorted ascending
by LT: [A, B]):
| Scenario | Probability | Wait (= max LT) | Counted under |
|---|---|---|---|
| Neither short | FR_A × FR_B | 0 | — |
| Only A short (B available) | (1−FR_A) × FR_B | LT_A | A's term |
| B short (A either state) | (1−FR_B) | LT_B | B's term |
| ↳ of which: both short | (1−FR_A) × (1−FR_B) | LT_B (= max) | B's term (A excluded by ×FR_B) |
- A's term:
(1−0.95) × 0.90 × 2 = 0.09 - B's term:
(1−0.90) × 1.0 × 4 = 0.40(covers "only B short" + "both short") kit_wait = 0.49 wk
The 0.40 already absorbs the both-short overlap at wait = 4 (= max), not 2+4 = 6 (sequential). This holds as long as replenishment is parallel — the standard BOM assumption.
This wait time is propagated to the FG as current_wait_time, which inflates the FG's
effective lead time → the FG needs more safety stock to achieve the same fill rate.
The greedy-loop coupling (marginal.rs:617-639)¶
When the optimizer evaluates increasing COMP-L's buffer:
1. COMP-L's own group gain is computed (its own fill rate improves).
2. The optimizer recurses into COMP-L's kits → finds FG-L.
3. kit_probabilistic_wait_time(FG-L, COMP-L, new_comp_fr) computes the reduced wait
time (because COMP-L's fill rate is now higher → lower stockout probability).
4. The FG's fill rate is recomputed with the new (lower) wait time → improves.
5. The FG's group gain is added to COMP-L's total marginal value.
This is the balancing: the optimizer sees that investing in the cheap component (unit_cost=20) also benefits the expensive FG (unit_cost=200) via wait-time reduction. The component's marginal value is boosted by the downstream FG benefit, so the optimizer preferentially invests in the component.
The kit-wait-time propagation fix¶
The bug¶
After Phase 1 (ASL), every SKU's current_fill_rate was computed with
current_wait_time = 0. When the greedy loop's kit recursion computed the FG's fill
rate with a positive kit wait time, the FG always looked worse than its
optimistic baseline (wait=0) → the kit gain was always negative → the component
was never invested in → no balancing.
The fix — Phase 1b: propagate_kit_wait_times¶
A new phase between ASL and marginal-value initialization:
1. For every assembly (FG) that has components, compute kit_probabilistic_wait_time
using the components' post-ASL fill rates.
2. Set the assembly's current_wait_time and recompute current_fill_rate with that
wait time.
3. Commit the fill-rate delta to the target groups.
Now the greedy loop's kit recursion compares new (lower) kit wait vs current kit wait → positive gain → the optimizer balances component vs FG stock.
Files changed¶
| File | Change |
|---|---|
files/MEIO_v3/src/marginal.rs |
Added propagate_kit_wait_times() public function |
files/MEIO_v3/src/optimizer.rs |
Added Phase 1b call + import |
files/MEIO_v2/src/marginal.rs |
Same function (V2 signature: no use_empirical) |
files/MEIO_v2/src/optimizer.rs |
Same Phase 1b call + import |
files/MEIO/src/marginal.rs |
Same function (V1 signature: fill_rate_for_rop, V1 commit_groups) |
files/MEIO/src/optimizer.rs |
Same Phase 1b call + import |
files/meio_optimizer_v2.pyd |
Rebuilt (release) |
files/meio_optimizer_v3.pyd |
Rebuilt (release) |
files/meio_optimizer.pyd |
Rebuilt (release) |
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 (Mon) |
| BUY route type | id = 1 (name='External Purchase', planning_type='BUY') |
| MEIO optimizer | V2 (parametric, default); --v3 available |
| Optimizer .pyd | All three (V1/V2/V3) rebuilt with kit-wait-time propagation fix |
| Existing MEIO V3 test | pipeline_id=111 (from meio_v3_test_guide_2026-06-23.md) — coexists |
| Reserved test id range | items 9101201-9101204, sites 9101201-9101202 |
master.segment + scenario.config_segment |
Two separate tables; both must have the segment row (same id) |
PIPE_segment_membership (CH) |
Seeded directly (segmentation step not run by this test) |
| Run log | docs/meio_bom_balance_run_2026-06-24.log |
The test design¶
Two pairs — identical economics, only the BOM link differs¶
| Pair | FG item | COMP item | FG LT | COMP LT | FG cost | COMP cost | BOM |
|---|---|---|---|---|---|---|---|
| Linked | 9101201 @ 9101201 | 9101202 @ 9101201 | 2 wk | 4 wk | 200 | 20 | ✅ |
| Control | 9101203 @ 9101202 | 9101204 @ 9101202 | 2 wk | 4 wk | 200 | 20 | ❌ |
- Both pairs: demand 20/wk (varied: cycle {12,16,20,24,28}), 30 weeks history, no
received_date(parametric path). - The component is the bottleneck (longer LT = 4 wk → more demand-during-LT variability).
- The component is cheap (20 vs 200) — the optimizer should prefer investing in it.
- The FG and component live at the same site (required —
kit_probabilistic_wait_timelooks up the component atkit_site_id = kit_key.1).
Dedicated segment (isolated group gain)¶
Without a dedicated segment, the test SKUs land in "Default"/"All" (8633 SKUs, ~1628
demand/wk) — their 20/wk is <1.2% of the group → the group target is met by other SKUs
before the kit-coupled component gets invested in. The dedicated segment
(MEIO BOM Balance, fill_rate_target=0.98, total_direct_demand_rate=80) isolates
the group so the kit gain is the dominant driver.
BOM row¶
-- master.bill_of_material: FG-L assembles from COMP-L (same site)
INSERT INTO master.bill_of_material (item_id, site_id, child_item_id, child_site_id, child_qty, attach_rate)
VALUES (9101201, 9101201, 9101202, 9101201, 1.0, 1.0);
This gives FG-L components=[9101202] and COMP-L kits=[9101201].
Series reference tables¶
Test SKUs (4 series)¶
| Case | URL uid | Item ID | Item xuid | Site ID | Site xuid | Weekly qty | FG/COMP | LT (wk) | unit_cost |
|---|---|---|---|---|---|---|---|---|---|
| FG-L | 9101201_9101201 |
9101201 | MEIOBOM_FGL | 9101201 | MEIOBOM_SITE1 | 12-28 (varied) | FG | 2 | 200 |
| COMP-L | 9101202_9101201 |
9101202 | MEIOBOM_COMPL | 9101201 | MEIOBOM_SITE1 | 12-28 (varied) | Component | 4 | 20 |
| FG-C | 9101203_9101202 |
9101203 | MEIOBOM_FGC | 9101202 | MEIOBOM_SITE2 | 12-28 (varied) | FG | 2 | 200 |
| COMP-C | 9101204_9101202 |
9101204 | MEIOBOM_COMPC | 9101202 | MEIOBOM_SITE2 | 12-28 (varied) | Component | 4 | 20 |
Routes¶
| Case | Route type | item_id | site_id | lead_time | transit_time | pick_pack_time | Total LT (days) | LT (weeks) |
|---|---|---|---|---|---|---|---|---|
| FG-L | BUY (id=1) | 9101201 | 9101201 | 7 | 6 | 1 | 14 | 2.0 |
| COMP-L | BUY (id=1) | 9101202 | 9101201 | 14 | 13 | 1 | 28 | 4.0 |
| FG-C | BUY (id=1) | 9101203 | 9101202 | 7 | 6 | 1 | 14 | 2.0 |
| COMP-C | BUY (id=1) | 9101204 | 9101202 | 14 | 13 | 1 | 28 | 4.0 |
BOM¶
| item_id (FG) | site_id | child_item_id (COMP) | child_site_id | child_qty | attach_rate |
|---|---|---|---|---|---|
| 9101201 | 9101201 | 9101202 | 9101201 | 1.0 | 1.0 |
Actual verified results¶
From pipeline_id=119, scenario_id=34 (V2 parametric):
MEIO results (ClickHouse PIPE_meio_results)¶
item_id buffer fill_rt marg_val inv_val lt_wk wait_t direct
9101201 53.00 0.970 0.0008 10600.00 2.00 0.053 1 ← FG-L (linked)
9101202 100.00 0.987 0.0058 2000.00 4.00 0.000 1 ← COMP-L (linked)
9101203 53.00 0.972 0.0042 10600.00 2.00 0.000 1 ← FG-C (control)
9101204 100.00 0.987 0.0031 2000.00 4.00 0.000 1 ← COMP-C (control)
Linked vs control contrast¶
COMP-L vs COMP-C: buffer Δ = +0.00 fill_rate Δ = +0.0000 marg_val Δ = +0.0027
→ COMP-L has HIGHER marginal value (BOM coupling boosts component's downstream benefit) [EXPECTED]
→ buffers equal (both reach the 98% target; balancing visible in marg_val + FG wait_time)
FG-L vs FG-C: buffer Δ = +0.00 wait_time Δ = +0.053 fill_rate Δ = -0.0020
→ FG-L has non-zero wait_time (propagated from COMP-L via kit_probabilistic_wait_time) [EXPECTED]
→ FG-L fill_rate slightly lower (kit wait inflates effective LT) [EXPECTED]
Kit recursion evidence (run log)¶
'kit dependant' log lines: 6
'dependant SKU ... new_wait_time' lines: 1
dependant SKU 9101201:9101201 new_fill_rate=0.9699 new_wait_time=0.05
What the results demonstrate¶
| Signal | Linked | Control | Meaning |
|---|---|---|---|
| COMP marginal value | 0.0058 | 0.0031 | COMP-L's MV is 87% higher — the BOM coupling adds the downstream FG gain to the component's value |
| FG wait_time | 0.053 | 0.000 | FG-L carries a kit-induced wait time (its fill rate is penalized by the component's stockout risk) |
| FG fill_rate | 0.970 | 0.972 | FG-L's fill rate is slightly lower (the wait inflates its effective LT) — but it still meets the 0.98 group target because the component's high fill rate keeps the wait low |
| Dependant change committed | ✅ | ❌ | The kit recursion committed a DependantChange to FG-L (wait_time + fill_rate update) — the coupling is active in the greedy loop |
| Buffers | identical | identical | Both pairs reach the 0.98 target; the balancing is in the marginal value and wait_time paths, not the final buffer (the target is met either way) |
Phase 1b evidence (run log)¶
The FG-L's fill rate drops from 0.9474 (ASL, wait=0) to 0.8548 (with kit wait=0.053). This is the correct baseline — the FG's fill rate now accounts for the component's stockout risk. When the greedy loop increases COMP-L's buffer, the kit wait decreases → the FG's fill rate improves → positive gain → balancing.
How to drive the test¶
Full seed + run + verify (default — V2)¶
cd C:\allDev\ForecastAI2026.01\files
$env:PYTHONIOENCODING="utf-8"
python scripts\seed_meio_bom_balance.py
With V3 optimizer¶
Uses V3 (empirical bootstrap enabled, but noreceived_date → falls back to parametric).
The kit mechanism is identical in V2 and V3.
Seed only (no run)¶
Cleanup only¶
Removes all test artifacts (PG + CH + segment). See Risks & guards.Run via the API¶
# After seeding with --no-run:
curl -X POST "http://localhost:8002/api/meio/scenarios/<sid>/run?pipeline_id=<pid>"
Idempotency: every artifact is tagged (MEIO BOM Balance Lab pipeline,
MEIOBOM_* xuids, 91012xx ids, meio_bom_balance demand source, MEIO BOM Balance
segment) and removed before re-seeding. Consecutive runs get incrementing pipeline_ids.
Verification SQLs¶
BOM topology¶
-- PostgreSQL: BOM links for the test items
SELECT b.item_id AS fg_id, i.xuid AS fg_xuid,
b.child_item_id AS comp_id, ci.xuid AS comp_xuid,
b.child_qty, b.attach_rate
FROM master.bill_of_material b
JOIN master.item i ON i.id = b.item_id
JOIN master.item ci ON ci.id = b.child_item_id
WHERE b.item_id IN (9101201,9101202,9101203,9101204)
OR b.child_item_id IN (9101201,9101202,9101203,9101204);
MEIO results — linked vs control¶
-- ClickHouse: compare the 4 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_val,
leg_lead_time,
round(wait_time, 4) AS wait_time
FROM PIPE_meio_results
WHERE pipeline_id = 119 -- ← replace with actual pipeline_id
AND scenario_id = 34 -- ← replace with actual scenario_id
AND item_id IN (9101201,9101202,9101203,9101204)
ORDER BY item_id;
Kit recursion evidence (run log)¶
# Count kit-dependant recursion lines
(Get-Content docs\meio_bom_balance_run_2026-06-24.log |
Select-String "kit dependant").Count
# Find committed dependant changes (wait_time propagation)
Get-Content docs\meio_bom_balance_run_2026-06-24.log |
Select-String "dependant SKU.*new_wait_time"
# Phase 1b propagation evidence
Get-Content docs\meio_bom_balance_run_2026-06-24.log |
Select-String "propagate_kit_wait_times"
Dedicated segment group achievement¶
-- ClickHouse: the MEIO BOM Balance segment
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 = 119 -- ← replace with actual pipeline_id
AND scenario_id = 34 -- ← replace with actual scenario_id
AND group_name = 'MEIO BOM Balance';
Code changes¶
1. Kit-wait-time propagation (Phase 1b) — CRITICAL¶
Problem: After ASL, assemblies had current_fill_rate computed with wait_time=0.
The greedy loop's kit recursion computed a positive wait time → the FG always looked
worse than its optimistic baseline → negative kit gain → component never invested in.
Fix: Added propagate_kit_wait_times() in all three optimizer versions (V1/V2/V3).
Called between Phase 1 (ASL) and Phase 2 (marginal-value initialization). Computes
each assembly's kit-induced wait time from its components' post-ASL fill rates, sets
current_wait_time, recomputes current_fill_rate, and commits the delta to target
groups.
2. Seed script — files/scripts/seed_meio_bom_balance.py¶
Self-contained test: creates 4 SKUs (2 pairs), BOM link, dedicated segment, CH segment membership, MEIO scenario + pipeline, runs MEIO in-process, verifies the kit coupling.
3. Segment table dual-insert¶
The test discovered that scenario.config_segment (queried by the runner) and
master.segment (referenced by the FK on config_meio_parameter_set_segment) are
two separate tables. Both must have the segment row (same id) for the parameter-set
link and the optimizer target group to work. The seed script inserts into both.
Risks & guards¶
- Global SKU universe: MEIO loads ALL 587 catalogue SKUs (not just the 4 test SKUs).
The run summary (
sku_count=587) includes the whole catalogue. Always filter verification byitem_id IN (9101201,9101202,9101203,9101204). - Dedicated segment required: without the
MEIO BOM Balancesegment, the test SKUs land in catch-all segments (8633 SKUs) where their 20/wk demand is negligible → the group target is met by other SKUs before the kit-coupled component gets invested in. Always keep the dedicated segment. - Two segment tables:
scenario.config_segmentandmaster.segmentmust both have theMEIO BOM Balancerow (same id). The seed script handles this; manual cleanup must delete from both. - Optimizer .pyd rebuild: the kit-wait-time propagation fix requires rebuilt .pyd
files. The
files/meio_optimizer*.pydfiles must be the June 24 2026 builds (or later). If the old .pyd is loaded, Phase 1b won't run and the kit gain will be negative. --v3mode: V3 with empirical bootstrap is available, but the test SKUs have noreceived_date→ V3 falls back to parametric (same as V2). The kit mechanism is identical in both.- BOM child must be at same site as FG:
kit_probabilistic_wait_timelooks up the component atkit_site_id = kit_key.1(the FG's site). The seed script places both at the same site. If you manually edit the BOM, ensurechild_site_id = site_id.
Out of scope¶
- Multi-component kits: the test uses a single-component BOM (FG → 1 component).
The
kit_probabilistic_wait_timefunction supports multiple components (it sorts by LT and computes a weighted wait), but this isn't exercised here. - Multi-level BOM chains: the test uses a 2-level BOM (FG → component). Deeper chains (sub-components of components) are not tested.
- Transfer-route coupling: the
repl_site_idsmechanism (for TRANSFER routes) also propagates wait times, but via a different path (replenishment_wait_time). Not tested here — this test is BOM/kit-only. - Empirical bootstrap + BOM: the V3 run falls back to parametric (no
received_date). A combined empirical+BOM test would requirereceived_dateon both the FG and the component, which is left as a follow-up. - Asset mode (Erlang-B): not exercised — the test SKUs are not in asset mode.
- Budget-constrained balancing: the
max_budget=10Mis effectively unlimited. A budget-constrained scenario (where the optimizer must choose between component vs FG) would produce more dramatic buffer differences.