MEIO — EOQ→ROP Refinement & the Type-2 Loss Function

How the Economic Order Quantity refines the reorder point inside the MEIO engine: a V1 / V2 / V3 comparison and the plan to replace the EOQ averaging heuristic with the exact first-order loss function β = 1 − n₁(s)/Q.

Companion implementation plan: .kilo/plans/1782658808592-meio-eoq-rop-loss-function.md
Contents

1. The intuition — why ROP must drop as EOQ rises

Consider a continuous-review (s, Q) policy: when the inventory position drops to the reorder point s, an order of size Q (the EOQ) is placed. The order arrives after the lead time L. During that lead time, demand continues to arrive and the on-hand inventory is consumed.

The key insight is that a larger order quantity spreads the risk of a stockout over a longer cycle. Each replenishment cycle places the system back at inventory position = s + Q. A stockout within the cycle can only occur during the lead-time window, but the fraction of demand that is back-ordered depends on how much demand the cycle must satisfy relative to the shortage:

β = 1 − n₁(s) / Q

where n₁(s) = E[(X − s)⁺] is the first-order loss function (expected shortage) for demand-during-lead-time X at reorder point s.

Because n₁(s)/Q shrinks as Q grows, β rises with Q at fixed s. Therefore, to hold a target fill rate β* constant, s (the ROP, and hence the safety stock SS = s − d̅L) must decrease as the EOQ increases.

This is the planner's intuition, and it is the textbook result (Silver, Pyke & Peterson; Axsäter). It is also the reason the MEIO engine accepts an eoq field per SKU — the EOQ is not only a cycle-stock input, it is a safety-stock input.

2. Type-1 vs Type-2 service — the crux

There are two distinct “service level” definitions, and only one of them depends on the EOQ:

MetricDefinitionEOQ-dependent?
Cycle Service Level (Type-1, CSL) P(X ≤ s) = F(s) — probability of no stockout in a cycle No — depends only on s
Fill Rate (Type-2, β) 1 − n₁(s)/Q — fraction of demand satisfied directly from stock Yes — depends on s and Q
A common error is to size safety stock using the Type-1 formula SS = zₐ·σ·√L (which is EOQ-independent) and then claim the EOQ has no effect on the buffer. That is only true for the cycle service level; the fill rate — which is what MEIO's group targets actually constrain — is genuinely EOQ-sensitive. The MEIO engine's group fill_rate_target is a Type-2 objective, so the EOQ must enter the ROP math.

Worked example

Demand-during-lead-time X ~ Normal(μ=100, σ=30), target fill rate β* = 0.99:

EOQ Qn₁(s) needed = Q(1−β*)s solving n₁(s) = Q(1−β*)Safety stock s − μ
500.5≈ 100.4 (nearly at the mean)+0.4
1001.0≈ 92.3−7.7
2002.0≈ 82.6−17.4
5005.0≈ 70.0−30.0

As Q grows, the same target fill rate is achieved with a lower reorder point — eventually dropping below the mean lead-time demand (negative safety stock), which is correct: a large enough order quantity back-fills the shortage from the cycle's own surplus.

3. The (s, Q) loss-function theory

The first-order loss function is the expected shortage above a threshold:

n₁(s) = E[(X − s)⁺] = ∫s (1 − F(x)) dx   (continuous)
n₁(s) = Σk > s (k − s)·P(X = k)   (discrete)

The exact Type-2 fill rate is then:

β(s, Q) = 1 − n₁(s) / Q,    Q > 0

and the inverse (find s for a target β*) solves n₁(s) = Q(1 − β*).

Per-distribution loss functions

Distribution of Xn₁(s)
Normal N(μ, σ²)σ·L(z), where z = (s−μ)/σ and L(z) = φ(z) − z(1 − Φ(z)) is the standard normal loss
Poisson Pois(λ)discrete sum Σk>s (k−s)·PMF(k), or recursion n₁(s) = n₁(s−1) − (1 − F(s−1))
Gamma Γ(α, θ), mean μ = αθμ(1 − Fα+1(s)) − s(1 − Fα(s)), where Fk is the Gamma CDF with shape k
LogNormal (μ, σ²)E[X]·Φ((μ+σ²−ln s)/σ) − s·Φ((μ−ln s)/σ), E[X] = exp(μ+σ²/2)
Weibull (k, λ)numerical integral s (1 − F(x)) dx (Gauss–Legendre quadrature)
NegativeBinomial (r, p)discrete loss sum / recursion (as Poisson)
Empirical samples {xₑ}(1/N) Σ max(xₑ − s, 0) (mean excess)

The Normal case σ·L(z) is the standard “normal loss” tabulated in every inventory textbook; the others are the natural generalisations.

4. Current state — V1 (Python)

The legacy V1 implementation lives in files/MEIO/intial/. It is not the production MEIO path (production runs the Rust V2/V3 optimizer), but it is the historical reference and is intentionally left unchanged for legacy comparison.

The averaging forward loop

Distributions.py:90golden_formula_ROP2FR and the sibling poisson_distribution_ROP2FR (:32) compute the fill rate by averaging the CDF over the inventory-position range [ROP, ROP + EOQ]:

step = int(max(1, avgsize, math.ceil((eoq)/maxEoqFreq)))   # maxEoqFreq = 10
tested_eoq = 0
while tested_eoq < eoq + step:
    maxEoq = min(eoq, tested_eoq)
    fill_rate = ndtr(safetyStock / racine)                # normal CDF at ROP+tested_eoq
    total_fill_rate += fill_rate
    tested_eoq += step
return total_fill_rate / loop_counter                      # average over ~10 points

This is a midpoint / trapezoidal approximation of (1/Q) ∫F(s + q) dq, which is related to the loss function (the integral form of n₁ is (1/Q) ∫(1−F(s+q)) dq) but only coarsely. It effectively subtracts roughly eoq/2 from the buffer.

The −eoq/2 heuristic in the inverse & jump

The EOQ-blind gaps (V1)

5. Current state — V2/V3 (Rust)

The production optimizer lives in files/MEIO_v3/src/ and serves both V2 and V3 (the V3 additions are the empirical/bootstrap distributions; the fill-rate math path is shared). See meio_runner.py:1375 — V3 is the default, V2 is the explicit fallback.

The V2 eoq_step fix

math.rs:175 — the integration step is now driven by the route lot size (effective_lot_size(), i.e. route.mult_qty) instead of the demand-line avg_size. V1 used avg_size, which for weekly demand buckets made the step ~13× too large (meio_runner.py:1164). The forward averaging loop in distributions.rs:300 (golden_formula_rop_to_fr), :363 (poisson_rop_to_fr) and :381 (fitted_cdf_rop_to_fr) is structurally identical to V1 — same averaging approximation, just with a corrected step.

Where EOQ is applied correctly (V2/V3)

The EOQ-blind gaps (V2/V3)

The V3 empirical path

The V3 empirical / compound-Poisson / bucketed distributions (distributions.rs:42-82) route through fitted_cdf_rop_to_fr (:381), which uses the same averaging loop as the parametric distributions — so the EOQ effect is the same coarse midpoint approximation, not the exact empirical mean-excess loss function.

6. Current state — V3 standalone Python

files/inventory_engine/rop.py is the V3 reference / dashboard engine (a pure-Python empirical-bootstrap inventory optimiser). It is not the production MEIO write path today (the Rust optimizer writes PIPE_meio_results), but it is the documented “future” ROP engine and is exposed via the service-level-curve API (main.py:27560).

rop.py:19reorder_point:

def reorder_point(simulated_ltd_samples, service_level):
    return EmpiricalDistribution(simulated_ltd_samples).inv_cdf(service_level)

rop.py:73fill_rate_for_buffer:

def fill_rate_for_buffer(simulated_ltd_samples, buffer):
    return EmpiricalDistribution(simulated_ltd_samples).cdf(buffer)
Both are pure Type-1 (F(s), percentile / CDF) with no EOQ parameter at all. If this engine is ever wired into the production safety-stock computation without an eoq argument, every SKU's buffer would silently lose the EOQ-driven reduction. The improvement plan (below) adds an eoq=0.0 parameter that preserves Type-1 behaviour when eoq=0 and applies the exact loss function when eoq>0.

7. Version-comparison matrix

Code pathV1 PythonV2/V3 RustV3 Python reference
Forward fill rate fill_rate_for_rop averaging loop (coarse ~−Q/2) averaging loop (V2 lot-size step) CDF(buffer)no EOQ
Inverse ROP (Poisson) ppf(β)·avgsize − Q/2 k·avg_size − Q/2 n/a (percentile)
Inverse ROP (Normal) ignores EOQ ignores EOQ n/a (percentile)
Initial jump max(avg, fcst·avg − Q/2) max(lot, fcst·lot − Q/2) n/a
Further jump (Poisson) ppf(β+Δ) − Q/2 poisson_fr_to_rop n/a
Further jump (Normal) ignores EOQ ignores EOQ n/a
Min/max ROP bounds ignores EOQ ignores EOQ n/a
Empirical / fitted n/a averaging loop (coarse) percentile — no EOQ

= EOQ applied (heuristic). = EOQ-blind gap.

8. The three problems

1. Incompleteness

The Normal inverse (normal_fr_to_rop), the Normal further_jump branch, and the min_max_rop_qty floor are EOQ-blind in every version. Since high-volume SKUs (the bulk of inventory value) use the Normal path, most of the catalogue sees almost no EOQ-driven ROP reduction today.

2. Approximation quality

Where EOQ is applied, it is via the averaging forward loop (a midpoint proxy that effectively subtracts ~Q/2) and the explicit −eoq/2 heuristic in the inverse. This over-reduces the ROP for large Q relative to the exact loss function 1 − n₁(s)/Q, because the averaging integral is only the linear part of the loss function and ignores the curvature of n₁ for heavy-tailed distributions.

3. V3 percentile drops EOQ

inventory_engine/rop.py computes ROP = percentile(demand_over_LT, SL) — a pure Type-1 cycle service level with no Q. If wired into production safety stock, it would silently remove the EOQ benefit.

9. The improvement plan

Replace the EOQ averaging approximation with the exact Type-2 loss-function fill rate β = 1 − n₁(s)/Q across the Rust MEIO_v3 engine and the V3 Python inventory_engine reference. V1 Python is left as legacy.

Design decisions (resolved with the planner):

1. New loss module — files/MEIO_v3/src/math.rs (or loss.rs)

Implement n₁(s) and its inverse solve_n1(target, Q, β) → s for every DistributionType (see the per-distribution table above).

2. Forward fill rate — files/MEIO_v3/src/distributions.rs

Rewrite fill_rate_for_rop (:225) to a single exact formula:

if eoq <= 0.0 {
    fr = dist.cdf(buffer)                              // Type-1 fallback (transfer routes)
} else {
    fr = (1.0 - n1(dist, buffer) / eoq).clamp(0.0, 1.0) // Type-2 exact
}

Delete the tested_eoq averaging loops in golden_formula_rop_to_fr (:300), normal_rop_to_fr (:344), poisson_rop_to_fr (:363), fitted_cdf_rop_to_fr (:381).

3. Inverse ROP — files/MEIO_v3/src/distributions.rs

4. Jumps & bounds — files/MEIO_v3/src/marginal.rs

5. V3 Python reference — files/inventory_engine/rop.py

6. Tests & validation

Full task breakdown: see the implementation plan at .kilo/plans/1782658808592-meio-eoq-rop-loss-function.md.

10. Risks & validation

Every SKU's buffer changes. The averaging proxy over-reduces the ROP at large Q vs the exact loss function. After the rewrite, most high-EOQ SKUs will see committed_buffer rise toward the exact value (still a decrease vs the eoq=0 baseline — the EOQ benefit remains, just calibrated). Run a dev-pipeline A/B and flag any group that falls below its fill_rate_target.

11. Key files

FileRole
files/MEIO/intial/Distributions.pyV1 legacy Python (averaging loop, −eoq/2, EOQ-blind Normal inverse)
files/MEIO/intial/MEIO.pyV1 legacy greedy optimizer (initialJump, further_jump, minMaxROPQty)
files/MEIO_v3/src/distributions.rsV2/V3 Rust forward fill_rate_for_rop + inverse *_fr_to_rop
files/MEIO_v3/src/marginal.rsV2/V3 Rust initial_jump, further_jump, min_max_rop_qty
files/MEIO_v3/src/math.rseoq_step (V2 lot-size fix); target for the new loss module
files/inventory_engine/rop.pyV3 Python reference (pure Type-1 percentile today)
files/meio_runner.pyLoads eoq / lot_size / avg_size per SKU (:943, :1163) and dispatches the optimizer (:375)
.kilo/plans/1782658808592-meio-eoq-rop-loss-function.mdImplementation plan (this chapter's companion)