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
safetyStock(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 99safetyStock(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 140safetyStock(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.
export function safetyStock(input: SafetyStockInput): number
| input | SafetyStockInput | |
| returns | int | the buffer in whole units, rounded up |
The types it declares, generated into your project
export type SafetyStockMethod = "service-level" | "max-minus-average";
/** What each method needs; fields the chosen method does not use may be null. */
export interface SafetyStockInput {
readonly method: SafetyStockMethod;
/** service-level: cycle service level, 9500 = 95%; one of the levels in the z-score table */
readonly serviceLevelBasisPoints: number | null;
/** service-level: standard deviation of demand per period (a day, a week) */
readonly demandStdDev: number | null;
/** lead time in the same periods; the average lead time for max-minus-average */
readonly leadTime: number;
/** average demand per period; max-minus-average, and service-level when lead time varies */
readonly averageDemand: number | null;
/** service-level: standard deviation of lead time, in periods; null when lead time is fixed */
readonly leadTimeStdDev: number | null;
/** max-minus-average: the highest demand in one period */
readonly maxDemand: number | null;
/** max-minus-average: the longest lead time, in periods */
readonly maxLeadTime: number | null;
}
Your code names it in one line, in the file that uses it
import { safetyStock } from "#fune/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 { Z_SCORES } from "./inventory_safety_stock_data.ts"; ← this capability’s own data, compiled from data/z-scores.json into the same file by fune build
import { type SafetyStockInput } from "./inventory_safety_stock_types.ts";
function amount(name: string, value: number | null, method: string): number {
if (value === null || value === undefined) {
throw new RangeError(`${method} needs ${name}`);
}
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new RangeError(`${name} must be a finite number, not negative, received ${value}`);
}
return value;
}
// 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.
function wholeUnitsUp(x: number): number {
const y = x * 1e6;
let micro = Math.floor(y);
if (y - micro >= 0.5) micro += 1;
return Math.floor((micro + 999999) / 1e6);
}
/** Safety stock in whole units, by the service-level or max-minus-average method. */
export function safetyStock(input: SafetyStockInput): number {
const leadTime = amount("leadTime", input.leadTime, input.method);
if (input.method === "service-level") {
const level = input.serviceLevelBasisPoints;
if (level === null || level === undefined) throw new RangeError("service-level needs serviceLevelBasisPoints");
const row = Z_SCORES.find((r) => r.serviceLevelBasisPoints === level);
if (!row) {
throw new RangeError(`no z-score for a service level of ${level} basis points: use one of ${Z_SCORES.map((r) => r.serviceLevelBasisPoints).join(", ")}`);
}
const sd = amount("demandStdDev", input.demandStdDev, input.method);
let variance = leadTime * sd * sd;
const sl = input.leadTimeStdDev ?? 0;
amount("leadTimeStdDev", sl, input.method);
if (sl > 0) {
const d = amount("averageDemand", input.averageDemand, "service-level with a varying lead time");
variance = variance + d * d * sl * sl;
}
return wholeUnitsUp((row.zTenThousandths / 10000) * Math.sqrt(variance));
}
if (input.method === "max-minus-average") {
const maxDemand = amount("maxDemand", input.maxDemand, input.method);
const maxLeadTime = amount("maxLeadTime", input.maxLeadTime, input.method);
const averageDemand = amount("averageDemand", input.averageDemand, input.method);
const worst = maxDemand * maxLeadTime;
const usual = averageDemand * leadTime;
if (worst < usual) {
throw new RangeError(`maxDemand x maxLeadTime (${worst}) must not be less than averageDemand x leadTime (${usual})`);
}
return wholeUnitsUp(worst - usual);
}
throw new RangeError(`unknown safety stock method "${input.method}"`);
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript 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 TypeScript implementation. Install it without the registry with fune add ./inventory.safety-stock-1.0.0-typescript.fune, or fetch it from a terminal with fune pull inventory.safety-stock@1.0.0:typescript.
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 |