Skip to content

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

  1. Goal & design rationale
  2. The BOM / kit balancing mechanism
  3. The kit-wait-time propagation fix
  4. Environment facts
  5. The test design
  6. Series reference tables
  7. Actual verified results
  8. How to drive the test
  9. Verification SQLs
  10. Code changes
  11. Risks & guards
  12. 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.rskit_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

kit_wait = Σ_i [ (1 − FR_i) × Π_{j: LT_j > LT_i} FR_j × LT_i ]

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

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_time looks up the component at kit_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)

propagate_kit_wait_times — assembly 9101201:9101201 wait_time 0.0000→0.0533  fill_rate 0.9474→0.8548

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
Cleans prior artifacts, seeds the 4 SKUs + BOM + dedicated segment, runs MEIO V2 in-process, and prints the verification table. Takes ~15s.

With V3 optimizer

python scripts\seed_meio_bom_balance.py --v3
Uses V3 (empirical bootstrap enabled, but no received_date → falls back to parametric). The kit mechanism is identical in V2 and V3.

Seed only (no run)

python scripts\seed_meio_bom_balance.py --no-run

Cleanup only

python scripts\seed_meio_bom_balance.py --clean
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 by item_id IN (9101201,9101202,9101203,9101204).
  • Dedicated segment required: without the MEIO BOM Balance segment, 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_segment and master.segment must both have the MEIO BOM Balance row (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*.pyd files 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.
  • --v3 mode: V3 with empirical bootstrap is available, but the test SKUs have no received_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_time looks up the component at kit_site_id = kit_key.1 (the FG's site). The seed script places both at the same site. If you manually edit the BOM, ensure child_site_id = site_id.

Out of scope

  • Multi-component kits: the test uses a single-component BOM (FG → 1 component). The kit_probabilistic_wait_time function 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_ids mechanism (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 require received_date on 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=10M is effectively unlimited. A budget-constrained scenario (where the optimizer must choose between component vs FG) would produce more dramatic buffer differences.