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.
pub fn safety_stock(input: &SafetyStockInput) -> i64
| input | SafetyStockInput | |
| returns | int | the buffer in whole units, rounded up |
The types it declares, generated into your project
// SafetyStockMethod is a string in Rust, one of: "service-level", "max-minus-average".
// Parameters take it as &str and results hold it as String.
/// What each method needs; fields the chosen method does not use may be null.
#[derive(Debug, Clone, PartialEq)]
pub struct SafetyStockInput {
pub method: String,
/// service-level: cycle service level, 9500 = 95%; one of the levels in the z-score table
pub service_level_basis_points: Option<i64>,
/// service-level: standard deviation of demand per period (a day, a week)
pub demand_std_dev: Option<f64>,
/// lead time in the same periods; the average lead time for max-minus-average
pub lead_time: f64,
/// average demand per period; max-minus-average, and service-level when lead time varies
pub average_demand: Option<f64>,
/// service-level: standard deviation of lead time, in periods; null when lead time is fixed
pub lead_time_std_dev: Option<f64>,
/// max-minus-average: the highest demand in one period
pub max_demand: Option<f64>,
/// max-minus-average: the longest lead time, in periods
pub max_lead_time: Option<f64>,
}
Your code names it in one line, in the file that uses it
fune!(inventory.safety-stock@^1); // then call safety_stock(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::inventory_safety_stock_data::Z_SCORES; ← this capability’s own data, compiled from data/z-scores.json into the same file by fune build
fn amount(name: &str, value: Option<f64>, method: &str) -> f64 {
match value {
None => panic!("{} needs {}", method, name),
Some(v) if !v.is_finite() || v < 0.0 => {
panic!("{} must be a finite number, not negative, received {}", name, v)
}
Some(v) => v,
}
}
// 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.
fn whole_units_up(x: f64) -> i64 {
let y = x * 1e6;
let mut micro = y.floor();
if y - micro >= 0.5 {
micro += 1.0;
}
(micro as i64 + 999_999).div_euclid(1_000_000)
}
/// Safety stock in whole units, by the service-level or max-minus-average method.
///
/// # Panics
/// Panics on an unknown method or service level, a missing or negative input,
/// or a worst case below the average case.
pub fn safety_stock(input: &SafetyStockInput) -> i64 {
let method = input.method.as_str();
let lead_time = amount("leadTime", Some(input.lead_time), method);
match method {
"service-level" => {
let level = match input.service_level_basis_points {
Some(l) => l,
None => panic!("service-level needs serviceLevelBasisPoints"),
};
let row = match Z_SCORES.iter().find(|r| r.service_level_basis_points == level) {
Some(r) => r,
None => panic!(
"no z-score for a service level of {} basis points: use one of {}",
level,
Z_SCORES
.iter()
.map(|r| r.service_level_basis_points.to_string())
.collect::<Vec<_>>()
.join(", ")
),
};
let sd = amount("demandStdDev", input.demand_std_dev, method);
let mut variance = lead_time * sd * sd;
let sl = amount("leadTimeStdDev", Some(input.lead_time_std_dev.unwrap_or(0.0)), method);
if sl > 0.0 {
let d = amount("averageDemand", input.average_demand, "service-level with a varying lead time");
variance = variance + d * d * sl * sl;
}
whole_units_up((row.z_ten_thousandths as f64 / 10000.0) * variance.sqrt())
}
"max-minus-average" => {
let max_demand = amount("maxDemand", input.max_demand, method);
let max_lead_time = amount("maxLeadTime", input.max_lead_time, method);
let average_demand = amount("averageDemand", input.average_demand, method);
let worst = max_demand * max_lead_time;
let usual = average_demand * lead_time;
if worst < usual {
panic!(
"maxDemand x maxLeadTime ({}) must not be less than averageDemand x leadTime ({})",
worst, usual
);
}
whole_units_up(worst - usual)
}
other => panic!("unknown safety stock method \"{}\"", other),
}
}
fn optional_f64(v: &Value) -> Option<f64> {
if v.is_null() {
None
} else {
Some(v.as_f64())
}
}
pub fn safety_stock_input_from_value(v: &Value) -> SafetyStockInput {
let level = v.get("serviceLevelBasisPoints");
SafetyStockInput {
method: v.get("method").as_str().to_string(),
service_level_basis_points: if level.is_null() { None } else { Some(level.as_i64()) },
demand_std_dev: optional_f64(v.get("demandStdDev")),
lead_time: v.get("leadTime").as_f64(),
average_demand: optional_f64(v.get("averageDemand")),
lead_time_std_dev: optional_f64(v.get("leadTimeStdDev")),
max_demand: optional_f64(v.get("maxDemand")),
max_lead_time: optional_f64(v.get("maxLeadTime")),
}
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Int(safety_stock(&safety_stock_input_from_value(&args[0])))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./inventory.safety-stock-1.0.0-rust.fune, or fetch it from a terminal with fune pull inventory.safety-stock@1.0.0:rust.
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 |