Functional Weave
Code in Rust

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 99
  • safety_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 140
  • safety_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
inputSafetyStockInput
returnsintthe 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(…)
impl/rust.rs · 102 lines · open · raw

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
Download for Rust inventory.safety-stock-1.0.0-rust.fune · 18,127 bytes sha256 a5821c83187e45e9d6ae5df7b3fbb2151952130154305bb36b562807477499f2

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md2,403
data/z-scores.json1,068
impl/python.py2,751
impl/rust.rs4,045
impl/typescript.ts2,617
vectors.json6,454