inventory.safety-stock
Safety stock in whole units, by service level and demand variability (z-score) or by max-minus-average.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 22 tests, run in TypeScript, Python and Rust.
What it does
Safety stock is the buffer held on top of expected lead-time demand, so that ordinary ups and downs in demand or supply do not cause a stock-out. Two methods, chosen by `method`:
**`service-level`** - the statistical method:
For example
safety_stock(method service-level, service level basis points 95%, demand std dev 20, lead time 9, average demand —, lead time std dev —, max demand —, max lead time —)→ 99 95%, daily sd 20 over a 9-day lead time: 1.6449 x 20 x 3 = 98.69, so 99safety_stock(method service-level, service level basis points 99%, demand std dev 20, lead time 9, average demand —, lead time std dev —, max demand —, max lead time —)→ 140 99%, same demand: 2.3263 x 20 x 3 = 139.58, so 140safety_stock(method service-level, service level basis points 97.5%, demand std dev 10, lead time 1, average demand —, lead time std dev —, max demand —, max lead time —)→ 20 97.5% is z = 1.96: 1.96 x 10 x 1 = 19.6, so 20
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def safety_stock(input: SafetyStockInput) -> int
| input | SafetyStockInput | |
| returns | int | the buffer in whole units, rounded up |
The types it declares, generated into your project
SafetyStockMethod = Literal["service-level", "max-minus-average"]
@dataclass(frozen=True)
class SafetyStockInput:
"""What each method needs; fields the chosen method does not use may be null."""
method: SafetyStockMethod
#: service-level: cycle service level, 9500 = 95%; one of the levels in the z-score table
service_level_basis_points: Optional[int]
#: service-level: standard deviation of demand per period (a day, a week)
demand_std_dev: Optional[float]
#: lead time in the same periods; the average lead time for max-minus-average
lead_time: float
#: average demand per period; max-minus-average, and service-level when lead time varies
average_demand: Optional[float]
#: service-level: standard deviation of lead time, in periods; null when lead time is fixed
lead_time_std_dev: Optional[float]
#: max-minus-average: the highest demand in one period
max_demand: Optional[float]
#: max-minus-average: the longest lead time, in periods
max_lead_time: Optional[float]
Your code names it in one line, in the file that uses it
from fune.inventory.safety_stock import safety_stock # inventory.safety-stock@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import math
from typing import Optional
from .inventory_safety_stock_data import Z_SCORES ← this capability’s own data, compiled from data/z-scores.json into the same file by fune build
from .inventory_safety_stock_types import SafetyStockInput
def _amount(name: str, value: Optional[float], method: str) -> float:
if value is None:
raise ValueError("%s needs %s" % (method, name))
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
raise ValueError("%s must be a finite number, not negative, received %r" % (name, value))
return float(value)
def _whole_units_up(x: float) -> int:
# Six decimal places, half away from zero, then up to whole units: float
# noise such as 55.00000000000001 must not cost a whole extra unit.
y = x * 1e6
micro = math.floor(y)
if y - micro >= 0.5:
micro += 1
return (micro + 999999) // 1000000
def safety_stock(input: SafetyStockInput) -> int:
"""Safety stock in whole units, by the service-level or max-minus-average method."""
lead_time = _amount("leadTime", input.lead_time, input.method)
if input.method == "service-level":
level = input.service_level_basis_points
if level is None:
raise ValueError("service-level needs serviceLevelBasisPoints")
row = next((r for r in Z_SCORES if r.service_level_basis_points == level), None)
if row is None:
raise ValueError(
"no z-score for a service level of %s basis points: use one of %s"
% (level, ", ".join(str(r.service_level_basis_points) for r in Z_SCORES))
)
sd = _amount("demandStdDev", input.demand_std_dev, input.method)
variance = lead_time * sd * sd
sl = _amount("leadTimeStdDev", 0.0 if input.lead_time_std_dev is None else input.lead_time_std_dev, input.method)
if sl > 0:
d = _amount("averageDemand", input.average_demand, "service-level with a varying lead time")
variance = variance + d * d * sl * sl
return _whole_units_up((row.z_ten_thousandths / 10000) * math.sqrt(variance))
if input.method == "max-minus-average":
max_demand = _amount("maxDemand", input.max_demand, input.method)
max_lead_time = _amount("maxLeadTime", input.max_lead_time, input.method)
average_demand = _amount("averageDemand", input.average_demand, input.method)
worst = max_demand * max_lead_time
usual = average_demand * lead_time
if worst < usual:
raise ValueError(
"maxDemand x maxLeadTime (%s) must not be less than averageDemand x leadTime (%s)" % (worst, usual)
)
return _whole_units_up(worst - usual)
raise ValueError('unknown safety stock method "%s"' % (input.method,))Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add inventory.safety-stock
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./inventory.safety-stock-1.0.0-python.fune, or fetch it from a terminal with fune pull inventory.safety-stock@1.0.0:python.
The whole function, every language, is one file too: inventory.safety-stock-1.0.0.fune, 23,713 bytes, sha256 d75657642e13f1c5f479aa6efcabc22b9f8cbee52afacec946682c4e660bdb76. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before inventory.safety-stock
after — your function gets the result and the arguments, and returns the final result.
# fune: after inventory.safety-stock
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show inventory.safety-stock --steps.
# fune: step inventory.safety-stock after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| 95%, daily sd 20 over a 9-day lead time: 1.6449 x 20 x 3 = 98.69, so 99 | method service-level, service level basis points 95%, demand std dev 20, lead time 9, average demand —, lead time std dev —, max demand —, max lead time — | → | 99 |
| 99%, same demand: 2.3263 x 20 x 3 = 139.58, so 140 | method service-level, service level basis points 99%, demand std dev 20, lead time 9, average demand —, lead time std dev —, max demand —, max lead time — | → | 140 |
| 97.5% is z = 1.96: 1.96 x 10 x 1 = 19.6, so 20 | method service-level, service level basis points 97.5%, demand std dev 10, lead time 1, average demand —, lead time std dev —, max demand —, max lead time — | → | 20 |
| 50% needs no buffer | method service-level, service level basis points 50%, demand std dev 20, lead time 9, average demand —, lead time std dev —, max demand —, max lead time — | → | 0 |
| steady demand needs no buffer | method service-level, service level basis points 95%, demand std dev 0, lead time 9, average demand —, lead time std dev —, max demand —, max lead time — | → | 0 |
| fractional inputs: 1.6449 x sqrt(2.5 x 12.5^2) = 32.51, so 33 | method service-level, service level basis points 95%, demand std dev 12.5, lead time 2.5, average demand —, lead time std dev —, max demand —, max lead time — | → | 33 |
| a varying lead time: 1.6449 x sqrt(4 x 10^2 + 50^2 x 1^2) = 88.58, so 89 | method service-level, service level basis points 95%, demand std dev 10, lead time 4, average demand 50, lead time std dev 1, max demand —, max lead time — | → | 89 |
| 90% with a varying lead time: 1.2816 x sqrt(16 x 25^2 + 40^2 x 2^2) = 164.12, so 165 | method service-level, service level basis points 90%, demand std dev 25, lead time 16, average demand 40, lead time std dev 2, max demand —, max lead time — | → | 165 |
| a lead-time sd of 0 is a fixed lead time and needs no average demand | method service-level, service level basis points 95%, demand std dev 20, lead time 9, average demand —, lead time std dev 0, max demand —, max lead time — | → | 99 |
| max-minus-average: 120 x 10 - 80 x 7 = 640 | method max-minus-average, service level basis points —, demand std dev —, lead time 7, average demand 80, lead time std dev —, max demand 120, max lead time 10 | → | 640 |
Show the other 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| max-minus-average, fractional: 2.2 x 25 - 1 x 5 is 50 exactly, though the float is 50.00000000000001 | method max-minus-average, service level basis points —, demand std dev —, lead time 5, average demand 1, lead time std dev —, max demand 2.2, max lead time 25 | → | 50 |
| max-minus-average rounds up a real fraction: 10.5 x 3 - 10 x 3 = 1.5, so 2 | method max-minus-average, service level basis points —, demand std dev —, lead time 3, average demand 10, lead time std dev —, max demand 10.5, max lead time 3 | → | 2 |
| max equal to average needs no buffer | method max-minus-average, service level basis points —, demand std dev —, lead time 7, average demand 80, lead time std dev —, max demand 80, max lead time 7 | → | 0 |
| a service level not in the table is an error | method service-level, service level basis points 96.5%, demand std dev 20, lead time 9, average demand —, lead time std dev —, max demand —, max lead time — | → | error: no z-score for a service level of 9650 basis points |
| service-level without a service level is an error | method service-level, service level basis points —, demand std dev 20, lead time 9, average demand —, lead time std dev —, max demand —, max lead time — | → | error: service-level needs serviceLevelBasisPoints |
| service-level without a demand deviation is an error | method service-level, service level basis points 95%, demand std dev —, lead time 9, average demand —, lead time std dev —, max demand —, max lead time — | → | error: service-level needs demandStdDev |
| a varying lead time without average demand is an error | method service-level, service level basis points 95%, demand std dev 20, lead time 9, average demand —, lead time std dev 1, max demand —, max lead time — | → | error: needs averageDemand |
| a negative standard deviation is an error | method service-level, service level basis points 95%, demand std dev -1, lead time 9, average demand —, lead time std dev —, max demand —, max lead time — | → | error: demandStdDev must be a finite number, not negative |
| a negative lead time is an error | method service-level, service level basis points 95%, demand std dev 20, lead time -9, average demand —, lead time std dev —, max demand —, max lead time — | → | error: leadTime must be a finite number, not negative |
| max-minus-average without a maximum is an error | method max-minus-average, service level basis points —, demand std dev —, lead time 7, average demand 80, lead time std dev —, max demand —, max lead time 10 | → | error: max-minus-average needs maxDemand |
| a worst case below the average case is an error | method max-minus-average, service level basis points —, demand std dev —, lead time 7, average demand 80, lead time std dev —, max demand 70, max lead time 7 | → | error: must not be less than averageDemand x leadTime |
| an unknown method is an error | method guess, service level basis points 95%, demand std dev 20, lead time 9, average demand —, lead time std dev —, max demand —, max lead time — | → | error: unknown safety stock method |
More from the author
safety stock = z x sqrt(L x sd^2 + d^2 x sL^2)
where `z` is the standard normal quantile for the service level, `L` the average lead time, `sd` the standard deviation of demand per period, `d` the average demand per period and `sL` the standard deviation of the lead time. When lead time is fixed (`leadTimeStdDev` null or 0) this is the familiar `z x sd x sqrt(L)`, and `averageDemand` is not needed. Demand and lead time must be in the same period: a daily standard deviation with a lead time in days. `stats.standard-deviation` gives `sd` from a demand history.
The service level is the **cycle service level**: the chance of not running out during one replenishment cycle. It is not the fill rate. `z` comes from a table (`data/z-scores.json`) of the levels people actually use, 50% to 99.9%, rather than an inverse normal function, which would be approximated differently in each language. Each value is the quantile rounded to four decimal places, e.g. 95% is 1.6449 and 97.5% is 1.9600. A level not in the table is an error; round to one that is.
**`max-minus-average`** - the rule of thumb that needs no statistics:
safety stock = maxDemand x maxLeadTime - averageDemand x leadTime
It is an error for the worst case to be below the average case.
**Rounding.** Both methods compute in floating point, which is fine for a statistical estimate but can land a whisker above a whole number (`2.2 x 25` is `55.00000000000001`). The figure is rounded to 6 decimal places (half away from zero) and then **up** to whole units, so 50.000000000000014 is 50 and not 51, and 98.694 is 99. All values must be finite and not negative.
Sources: z values are the standard normal quantiles, checked against NIST/SEMATECH e-Handbook of Statistical Methods, section 1.3.6.7.1, "Cumulative Distribution Function of the Standard Normal Distribution" (https://www.itl.nist.gov/div898/handbook/eda/section3/eda3671.htm), and computed to four places with Python's `statistics.NormalDist().inv_cdf`. The combined-variability formula is the standard one in Silver, Pyke and Thomas, *Inventory and Production Management in Supply Chains*, 4th ed., ch. 6.
Files
| Path | Bytes |
|---|---|
| README.md | 2,403 |
| data/z-scores.json | 1,068 |
| impl/python.py | 2,751 |
| impl/rust.rs | 4,045 |
| impl/typescript.ts | 2,617 |
| vectors.json | 6,454 |