Skip to content

K-Curve (Exchange Curve)

Chapter summary

The K-Curve (also called the exchange curve or service-investment curve) plots the trade-off between inventory investment and achieved service level across a portfolio of SKUs. This chapter derives the curve from first principles, explains the Mirabelle implementation, and shows how planners use it for budget negotiations and scenario analysis.


What is the K-Curve?

Figure: the hyperbolic exchange curve — InvVal × Orders = A·B = const. k<1 shifts right (more inventory, fewer orders), k=1 is the EOQ point, k>1 shifts left (less inventory, more orders). Any point on the curve is Pareto-efficient; inside is dominated.

flowchart LR
  K1["k < 1<br/>larger Q, fewer orders<br/>↑ InvVal, ↓ Orders"] --- KEQ["k = 1<br/>each item at its EOQ<br/>min total cost"]
  KEQ --- K2["k > 1<br/>smaller Q, more orders<br/>↓ InvVal, ↑ Orders"]
  K1 -.->|"inv_val · orders = A·B"| K2
  KEQ --> OPT["Pareto-efficient frontier<br/>(hyperbolic exchange curve)"]

The K-Curve answers the question: "If we invest \(X\) in cycle stock, how many purchase orders per year will we need?" Equivalently, "If we want to cut our order frequency in half, how much more inventory must we carry?"

For a single item the Economic Order Quantity (EOQ) model says:

\[ Q^* = \sqrt{\frac{2DS}{H}}, \quad TC(Q^*) = \sqrt{2DSH} \]

There is no free parameter — \(Q^*\) is uniquely determined by demand \(D\), ordering cost \(S\), and holding cost rate \(H\). But in practice, companies may choose to deviate from \(Q^*\) by applying a scalar factor \(k\) to the holding cost:

\[ Q(k) = \sqrt{\frac{2DS}{kH}} \]

When \(k = 1\) we are at EOQ; \(k < 1\) means we order more often (lower inventory, higher ordering cost); \(k > 1\) means we order less often (higher inventory, lower ordering cost).

Applying the same \(k\) to every SKU in a portfolio and tracing the resulting portfolio inventory value and order frequency as \(k\) varies from 0 to \(\infty\) traces out the K-Curve (exchange curve).


Mathematical derivation

Single-item metrics at factor k

For a single item with annual demand \(D\), ordering cost \(S\), unit price \(p\), and holding rate \(h\) (fraction of price per year):

\[ Q(k) = \sqrt{\frac{2DS}{k\,p\,h}} \]
\[ \text{Cycle stock value} = \frac{Q(k)}{2} \cdot p = \frac{p}{2}\sqrt{\frac{2DS}{k\,p\,h}} = \sqrt{\frac{DS\,p}{2kh}} \]
\[ \text{Orders per year} = \frac{D}{Q(k)} = \sqrt{\frac{Dk\,p\,h}{2S}} \]

Portfolio aggregation

For a portfolio of \(N\) items, aggregate over all items at the same \(k\):

\[ \text{InvVal}(k) = \sum_{i=1}^N \frac{Q_i(k)}{2} \cdot p_i = \frac{1}{\sqrt{k}} \sum_{i=1}^N \sqrt{\frac{D_i\,S_i\,p_i}{2h_i}} \]
\[ \text{Orders}(k) = \sum_{i=1}^N \frac{D_i}{Q_i(k)} = \sqrt{k} \sum_{i=1}^N \sqrt{\frac{D_i\,p_i\,h_i}{2S_i}} \]

Let \(A = \sum_i \sqrt{D_i S_i p_i / (2h_i)}\) and \(B = \sum_i \sqrt{D_i p_i h_i / (2S_i)}\). Then:

\[ \text{InvVal}(k) = \frac{A}{\sqrt{k}}, \quad \text{Orders}(k) = B\sqrt{k} \]

Eliminating \(k\):

\[ \text{InvVal} \cdot \text{Orders} = A \cdot B = \text{const} \]

This is the hyperbolic exchange curve: the product of inventory investment and order frequency is constant across the entire feasible frontier. Moving along the curve reduces one at the expense of the other; moving away from the curve is sub-optimal.

Managerial interpretation

The K-Curve shows that you cannot simultaneously reduce both inventory and order frequency. Any policy below the curve (inside the hyperbola) is dominated. Any policy on the curve is Pareto-efficient.


Implementation in Mirabelle

The _kcurve_metrics function

files/kcurve/runner.py implements the core calculation in _kcurve_metrics:

def _kcurve_metrics(k: float, items: list) -> tuple:
    """Return (Q_list, inv_val, orders_per_year) for a given k factor."""
    Q_list, inv_val, orders = [], 0.0, 0.0
    for it in items:
        D = it["annual_demand"]
        S = it["order_cost"]
        h = it["price"] * it["holding_rate"]
        if D > 0 and S > 0 and h > 0 and k > 0:
            Q = math.sqrt(2 * S * D / (h * k))
        else:
            Q = max(D, 1.0)
        Q_list.append(Q)
        inv_val += Q * it["price"] / 2
        if Q > 0:
            orders += D / Q
    return Q_list, inv_val, orders

Note the formula matches the derivation exactly: \(Q(k) = \sqrt{2SD/(hk)}\) where \(h = p \cdot \text{holding\_rate}\).

Curve generation — 80 log-spaced points

The exchange curve is traced by evaluating _kcurve_metrics at 80 values of \(k\) spaced logarithmically between \(10^{-3}\) and \(10^{1.5} \approx 31.6\):

k_vals = np.logspace(-3, 1.5, 80).tolist()
curve = []
for kv in k_vals:
    _, iv, ord_ = _kcurve_metrics(kv, items)
    curve.append({"k": round(kv, 6), "inv_val": round(iv, 2), "orders": round(ord_, 2)})

Log-spacing ensures good resolution near \(k = 1\) (the EOQ operating point) while also capturing the asymptotic extremes.

Budget-mode and orders-mode solving (Brent's method)

When the scenario parameter target_mode = 'budget', the runner solves for the \(k\) that achieves a specified inventory value target using Brent's root-finding method:

k = brentq(lambda kk: _kcurve_metrics(kk, items)[1] - target, 0.0001, 1000.0)

When target_mode = 'orders', the same approach solves for a target number of orders per year:

k = brentq(lambda kk: _kcurve_metrics(kk, items)[2] - target, 0.0001, 1000.0)

Brent's method is guaranteed to converge when the bracket \([0.0001, 1000]\) straddles the root — which it always does because InvVal(k) is strictly decreasing and Orders(k) is strictly increasing in \(k\).

In Explore mode, no solver is needed — the user clicks a point on the curve and the corresponding \(k\) is read directly from the curve data.


Demand sourcing

The items loaded for K-Curve computation use the most accurate demand available for the current pipeline:

Priority 1 — Pipeline-scoped forecast netting:

The forecast_netting table combines statistical forecast, maintenance forecast, and causal forecast into a total forward-looking demand (total_forecast_qty). Annual demand is annualized from the future horizon:

\[ D_{\text{annual}} = \frac{\sum_{\text{future weeks}} q_{\text{total}}}{\text{count of future weeks}} \times 52 \]

Indirect demand (from upstream warehouse aggregation) is added on top.

Priority 2 — Historical actuals (52-week rolling):

When no netting rows exist for the pipeline (e.g., forecasting has not yet been run), the runner falls back to annualizing the last 52 weeks of demand_actuals.

Cost data hierarchy

For each (item, site) pair, unit price and costs are resolved in priority order:

  1. item_site.price or item_site.cost_price (site-specific override)
  2. item.price or item.cost (item-level default)
  3. route.unit_cost (replenishment route cost)
  4. Hardcoded default: price = 1.0

Similarly for ordering cost: item_site.order_costroute.order_costparameters.order_cost (default: 50.0). Holding rate defaults to 20 % (holding_cost_percentage in parameters).


Per-item EOQ output

At the operating point \(k\), each item receives a computed EOQ:

Column Formula
eoq \(Q = D / f_{\text{chosen}}\) (frequency-snapped, not Wilson formula)
cycle_stock_value \(Q/2 \times p\)
orders_per_year \(D/Q\) (= chosen frequency \(f\))
annual_holding_cost \((Q/2) \times p \times h\)
annual_ordering_cost \((D/Q) \times S\)
total_annual_cost sum of above two
freq_constraint {param_name, selected_freq_label, selected_orders_per_year, allowed_orders_per_year, constrained}
freq_options Array of all allowed frequencies with per-frequency Q, cycle stock, cost, k-equivalent, and is_selected flag

These per-item EOQs are stored in inventory_scenario.results (JSONB) and consumed by the supply planning module when generating planned orders.

K-Curve EOQ takes priority over static item EOQ

When a pipeline is linked to an inventory scenario that has K-Curve results, the supply planning engine reads EOQ from inventory_scenario.results (the portfolio-optimised value) via a CTE join on scenario_pipeline. It falls back to item.eoq (the static field seeded from the simple formula) only when no scenario is linked or the item has no K-Curve row.

-- Resolved EOQ priority in supply planning:
-- 1. K-Curve result from pipeline's linked inventory_scenario
-- 2. item.eoq (static fallback)
-- 3. 0 (no ordering cost configured)
COALESCE(eoq_scenario.eoq_qty, NULLIF(item.eoq, 0.0), 0.0)

The K-Curve screen also displays an info note explaining this distinction: "Classic EOQ optimises each item independently. MEIO and Supply Planning use the K-Curve EOQ, which jointly optimises the full portfolio under a shared capital constraint."


Key points on the curve

Figure: comparison of the three k operating regimes — how Q(k), InvVal, and Orders move as k sweeps from <1 to >1; the same k multiplies every SKU in the portfolio.

flowchart TD
  K["Shared scalar k applied to every SKU<br/>Q(k) = sqrt(2DS / kH)"] --> LO["k < 1"]
  K --> EQ["k = 1"]
  K --> HI["k > 1"]
  LO --> LO1["Larger batches Q ↑"]
  LO1 --> LO2["InvVal ↑ (more cycle stock)"]
  LO1 --> LO3["Orders/yr ↓ (fewer POs)"]
  EQ --> EQ1["Each SKU at individual EOQ"]
  EQ1 --> EQ2["Minimises total annual cost"]
  EQ1 --> EQ3["Natural analysis start point"]
  HI --> HI1["Smaller batches Q ↓"]
  HI1 --> HI2["InvVal ↓ (leaner stock)"]
  HI1 --> HI3["Orders/yr ↑ (more POs)"]

The EOQ point (\(k = 1\))

At \(k = 1\), every item operates at its individual EOQ. This minimizes total cost for the portfolio. It is the natural starting point for any analysis.

The diminishing-returns zone

Moving from \(k = 1\) toward \(k \to \infty\) increases inventory while reducing orders. The marginal cost of the next order saved (in inventory terms) rises steeply:

\[ \frac{d(\text{InvVal})}{d(\text{Orders})} = -\frac{A}{B} \cdot \frac{1}{\text{Orders}^2} \]

This second derivative is always negative — each additional order saved costs more inventory than the previous one. The curve is convex.

Reading the knee

Figure: the knee of the K-Curve is the point of maximum curvature — highest marginal return on inventory investment; beyond it, large inventory increases yield diminishing order-frequency reductions.

quadrantChart
    title K-Curve — InvVal vs Orders (knee interpretation)
    x-axis "Few orders/yr (high k)" --> "Many orders/yr (low k)"
    y-axis "Low InvVal" --> "High InvVal"
    quadrant-1 "Over-invested<br/>(past knee, k<1)"
    quadrant-2 "Lean but busy<br/>(k>1)"
    quadrant-3 "Starved"
    quadrant-4 "Diminishing returns"
    "k=1 EOQ point": [0.50, 0.50]
    "knee (max curvature)": [0.40, 0.62]
    "k<1 (more stock, fewer orders)": [0.20, 0.85]
    "k>1 (less stock, more orders)": [0.75, 0.30]

The "knee" of the K-Curve is the region of maximum curvature — the point of highest marginal return on investment. Beyond the knee, large increases in inventory produce diminishing reductions in ordering frequency (or, for service-level curves, diminishing fill-rate improvements).

Mirabelle's K-Curve chart in the frontend marks the current operating point and allows the planner to slide along the curve interactively.


Scenario parameters

The inventory_scenario table stores K-Curve parameters in a JSONB parameters column:

Parameter Description
target_mode 'explore' — click a point on the curve (default); 'budget' — solve for k matching an inventory budget; 'orders' — solve for k matching a target orders/year
target_budget Total cycle stock value target in currency units (used when target_mode = 'budget')
target_orders Target orders per year (used when target_mode = 'orders')
k Resolved k factor (output, not input — computed by the solver from the target)
ordering_cost_mult Ordering cost multiplier (scales all \(S_i\) values)
holding_rate_mult Holding rate multiplier (scales all \(h_i\) values)
frequency_groups Scenario-level frequency group definitions (segment-based constraints)

Results are persisted back to inventory_scenario.results and tagged with pipeline_id so the supply module and UI retrieve the pipeline-appropriate EOQ values.

Target modes

The exchange curve tab offers three target modes:

Mode UI Behaviour
Explore Hint text only Default. The curve is plotted freely; the user clicks a point to select an operating point. No target input.
Inv. Budget Log-scale slider The user drags a slider bounded by the curve's min/max inventory value. A brentq solver finds the \(k\) that produces that inventory value. Auto-solves on slider release (300 ms debounce).
Orders / Yr Log-scale slider The user drags a slider bounded by the curve's min/max orders per year. A brentq solver finds the \(k\) that produces that order frequency. Same debounce.

The K factor is never a user input — it is an internal curve parameterisation. The user selects a point on the curve (by clicking or via a slider), and the solver computes the corresponding \(k\) internally. The resolved \(k\) is shown as informational metadata in the KPI cards.


Pipeline integration

The K-Curve / EOQ step is triggered by:

POST /api/pipeline/run/eoq
Body: { pipeline_id: <int> }

Which calls run_eoq_for_pipeline(pipeline_id) in files/kcurve/runner.py. The linked inventory_scenario_id is resolved from scenario_pipeline.

The scenario tag 'kcurve' in inventory_scenario_tag identifies this as an EOQ scenario (as opposed to a safety-stock or reorder-point scenario).


Using the K-Curve for budget negotiations

A common management conversation goes:

"Our procurement team can handle at most 5 000 purchase orders per year. How much inventory does that require?"

Procedure:

  1. Open the Exchange Curve tab — the curve is plotted automatically in Explore mode.
  2. Switch to Orders / Yr mode and drag the slider to 5 000 — the solver finds the \(k\) that produces exactly 5 000 orders/year.
  3. Read the resulting cycle stock value from the KPI card. The "Selected" star marker on the curve shows the operating point.
  4. Alternatively, set an inventory budget in Inv. Budget mode and read the implied orders/year.
  5. Use the curve visualization to show management the trade-off: cutting orders from 8 000/year to 5 000/year may require 60 % more inventory (illustrative — actual numbers depend on your portfolio).

The Item Frequencies tab shows the per-item breakdown: for each SKU, the chosen frequency (e.g., "Monthly"), the EOQ (= demand / chosen frequency), and all possible frequencies with their Q, cycle stock, and total cost. Expand any row to see the full frequency option table with k-equivalents.


Relationship to MEIO safety stock

The K-Curve determines cycle stock (order quantity / 2). MEIO determines safety stock (committed buffer). Total inventory investment is:

\[ \text{Total inventory} = \underbrace{\frac{Q^*}{2}}_{\text{cycle stock}} + \underbrace{SS}_{\text{safety stock}} \]

Both components are optimized independently in Mirabelle:

  • K-Curve / EOQ: minimizes total ordering + holding cost for cycle stock.
  • MEIO: minimizes total safety-stock investment subject to service-level targets.

The EOQ computed by the K-Curve runner is written to item.eoq and used by MEIO when consider_eoq = true — the greedy loop snaps buffer increments to EOQ multiples, ensuring safety stock tops up neatly to the next order quantity boundary.


Order-frequency constraints

Motivation

In practice, items are often constrained to order at certain allowed frequencies — weekly, monthly, quarterly, or annually — due to supplier contracts, container sizes, or planning calendar constraints. The unconstrained EOQ model may suggest an order quantity that implies an intermediate frequency (e.g., 8.3 orders/year), which is infeasible.

Mirabelle implements snap-to-grid K points: the K-Curve model is preserved, but for each candidate \(k\), every item's \(Q(k)\) is snapped to the nearest allowed frequency that minimises total annual cost. This produces a stepped constrained exchange curve that replaces the smooth hyperbola with a piecewise-linear frontier.

Configuration: the order_frequency parameter

The order_frequency parameter type is registered in config_parameters and follows the standard segment-based override mechanism:

Level Source Priority
Segment override config_parameter_segment joined with PIPE_segment_membership 1 (highest)
Default parameter config_parameters where is_default = TRUE 2

The parameter value is a JSON object:

{
  "allowed_orders_per_year": ["weekly", "monthly", "quarterly", "annually"]
}

Constraints are always active

The K-Curve algorithm always snaps each item to a frequency. When no explicit frequency groups or DB-based order_frequency parameters are configured, the system falls back to all frequency definitions (daily, weekly, bi-weekly, monthly, bi-monthly, quarterly, semi-annually, annually) as the allowed set for every item. This ensures the Item Frequencies tab always shows both the chosen frequency and the possible frequencies for every item — the core output of the K-Curve algorithm.

The unconstrained (smooth Wilson) curve is still shown for exploration; the constrained (stepped) curve is always overlaid as an orange trace.

String frequency names resolve via the FREQ_NAMES map:

Name Orders/year
daily 365
weekly 52
bi-weekly 26
monthly 12
bi-monthly 6
quarterly 4
semi-annually 2
annually 1

Numeric values are also accepted (e.g., 6.5 for 6.5 orders/year).

Snap-to-grid algorithm

The snap-to-grid algorithm is the core of the K-Curve method. The input is the set of available frequencies per item (constraints), and the output is the chosen frequency for each item — from which the EOQ is simply:

\[ Q = \frac{D}{f_{\text{chosen}}} \]

This is not the Wilson EOQ formula (\(\sqrt{2SD/H}\)). The Wilson formula is used only to draw the unconstrained exploration curve and to determine which frequency to snap to at a given \(k\).

For each item \(i\) and candidate \(k\):

  1. Compute the unconstrained \(Q(k) = \sqrt{2S_iD_i / (k\,p_i\,h_i)}\).
  2. For each allowed frequency \(f_j\), compute \(Q_j = D_i / f_j\).
  3. Snap: choose the \(Q_j\) whose unconstrained equivalent \(k\) is closest to the candidate \(k\): $$ k_j = \frac{2 S_i f_j^2}{p_i h_i D_i} $$ Tie-break by lowest total annual cost \(TC(Q_j) = S_i f_j + (Q_j/2) p_i h_i\).
  4. Mark constrained = True only when the snapped \(Q_j\) differs from the unconstrained \(Q(k)\) by more than 0.5 units — if the snap matches the unconstrained result, the item is effectively unconstrained.
  5. Return \(Q_j\) as the constrained EOQ for item \(i\) at this \(k\).

Constrained exchange curve

The constrained curve is built by:

  1. Starting with the same 80 log-spaced \(k\) grid as the unconstrained curve.
  2. Adding every threshold \(k\) where an item's unconstrained EOQ equals one of its allowed quantities (\(k_j\) values from above). At these thresholds the snap-to-grid assignment can change.
  3. Evaluating constrained_kcurve_metrics at each candidate \(k\) (snapping all items at each point).
  4. Deduplicating near-identical points to produce a clean stepped curve.

The result is returned as {"curve": [{k, inv_val, orders}], "target_metrics": ...}. When target_k is provided, target_metrics contains the constrained metrics at that exact k, avoiding a separate constrained_kcurve_metrics call.

API endpoints

Endpoint Parameters Behaviour
GET /api/kcurve/curve constrained: bool (default false), frequency_groups: JSON Returns curve (unconstrained). When constrained=true or frequency_groups provided, also returns constrained_curve. Falls back to default frequency definitions when no explicit constraints exist.
POST /api/kcurve/solve k, target_inv_val, or target_orders_per_year; constrained: bool; frequency_groups: JSON; optional scenario_id + persist Solves for k given the target, snaps each item to its best frequency, returns per-item freq_constraint (with selected_freq_label) and freq_options. Always falls back to default definitions. When scenario_id+persist are passed (the frontend sends this on an explicit curve click, not exploratory slider drags), the solve atomically merges items[] into scen_inventory_scenario.results for the active pipeline — the surface downstream MEIO/supply read for EOQ. master.item.eoq is intentionally not written here (that stays exclusive to the EOQ process step, kcurve.runner.run_eoq_for_pipeline) so a dev/what-if click cannot clobber production master data. The frontend suppresses its heavy solve auto-save payload for ~1.5 s after a persisted click so a stale pre-click timer cannot overwrite the just-persisted results.
GET /api/kcurve/eoq/item respect_frequency_constraints: bool (default true) Snaps per-item EOQ to its allowed frequency and returns freq_options. The snapped Q also respects min_qty/mult_qty lot-size constraints.

Pipeline runner integration

The EOQ pipeline runner (files/kcurve/runner.py) always applies frequency constraints. When the scenario has explicit frequency_groups, those are used. Otherwise, it falls back to DB-based order_frequency parameters (if constrained: true is set), and finally to all frequency definitions as the default allowed set.

The runner calls resolve_order_freq_params (or resolve_freq_params_from_groups) after loading items, builds the constrained curve, and writes frequency-constrained Q* values back to item.eoq. The inventory_scenario.results JSONB includes both curve and constrained_curve, plus per-item freq_constraint (with selected_freq_label) and freq_options.

UI locations

Order-frequency constraint information appears in three places:

  1. K-Curve workbench — Parameters tab — frequency group editor where users assign allowed frequencies to segments. Items not in any segment-based group fall into the Default group, which uses all frequency definitions.
  2. K-Curve workbench — Exchange Curve tab — the constrained curve is always overlaid as an orange stepped trace on the exchange curve chart. Three target modes: Explore (click a point), Inv. Budget (slider), Orders/Yr (slider). Feasible k alternatives are shown as clickable buttons.
  3. K-Curve workbench — Item Frequencies tab — per-item table showing chosen frequency (e.g., "Weekly"), EOQ (= demand / chosen frequency), orders/year, cycle stock, total cost, and all possible frequencies. Each row is expandable to show the full frequency option table with k-equivalents. Filter chips ("Can be:" / "Is:") allow filtering by available or chosen frequencies.
  4. Series detail / EOQ tab — a "Order Frequency Options" panel (eoq-frequencies) shows a table of allowed frequencies with Q, cycle stock, and total cost for each. The EOQ cost curve SVG overlays orange markers at each allowed frequency quantity on the total cost curve.

Shared module

All constraint logic lives in files/kcurve/frequency.py, imported by both files/api/main.py (API endpoints) and files/kcurve/runner.py (pipeline runner) to ensure identical algorithm behaviour. Key functions:

Function Purpose
load_freq_definitions() Loads canonical frequency list from DB (cached 5 min), falls back to FREQ_OPTIONS
default_freq_params_from_definitions(items) Builds default freq_params using all frequency definitions — ensures every item is always snapped to a frequency
resolve_order_freq_params(conn, schema, uids) Resolves DB-based order_frequency parameters via segment membership
resolve_freq_params_from_groups(items, groups, uid_to_segs) Resolves scenario-level frequency groups (segment-based)
snap_item_to_frequency(it, allowed, k) Snaps one item to its best allowed frequency at k — returns {freq, eoq, constrained, k_equivalent}
item_feasible_points(it, freq_params) Computes per-frequency candidate points for the UI (Q, cycle stock, cost, k-equivalent)
build_constrained_curve(items, freq_params, target_k) Builds the stepped constrained exchange curve with threshold k values

Rust acceleration (kcurve_engine)

The CPU-bound math — exchange-curve evaluation, brentq k-solving, the constrained snap-to-grid (with precomputed per-item candidate tables) and the O(T²) Wagner-Whitin DP — runs in the Rust crate files/kcurve_rs/ (PyO3 + rayon), exposed as the kcurve_engine Python extension. This mirrors the supply_engine pattern: msgpack fast path + JSON fallback, GIL released during parallel work, and a pure-Python fallback when the wheel is not built.

files/kcurve/rust_bridge.py dispatches between the Rust fast path and the Python fallback (frequency.py, runner.py, wagner_whitin.py); the API endpoints and pipeline runner call the bridge, keeping DB I/O, EOQ-override application and display rounding in Python. Build with:

cd files/kcurve_rs && maturin develop --release

At ≥100k items the constrained solve runs ~500× faster than the Python loop with bit-identical results (parity enforced by files/kcurve/tests/test_rust_parity.py).

Cross-references: EOQ · Safety Stock · MEIO Theory