Functional Weave
Code in Rust

monitor.anomaly

Is a new metric value an outlier against its history? By z-score or by the robust median/MAD modified z-score.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 20 tests, run in TypeScript, Python and Rust.

What it does

Says whether a new value of a metric (a latency, a queue depth, requests a minute) stands out from its recent history, and in which direction. Two methods:

- `stddev`: the z-score, `z = (value - mean) / sd`, with the population standard deviation of the history. An anomaly when `|z| > k`, with `k = thresholdHundredths / 100` (3.0 is the usual "three sigma"). - `mad`: the modified z-score of Iglewicz and Hoaglin, `M = 0.6745 (value - median) / MAD`, where MAD is the median of the absolute deviations from the median. An anomaly when `|M| > k`; they recommend 3.5 (`350`).

For example

  • detect_anomaly(2, 4, 4, 4, 5, 5, 7, 9, 12, stddev, 300) → anomaly true, direction high, center milli 5,000, spread milli 2,000, score hundredths 350 stddev: mean 5, sd 2, value 12 is z 3.5, above 3.0
  • detect_anomaly(2, 4, 4, 4, 5, 5, 7, 9, 11, stddev, 300) → anomaly false, direction normal, center milli 5,000, spread milli 2,000, score hundredths 300 stddev: z of exactly 3.0 against a threshold of 3.0 is not an anomaly
  • detect_anomaly(2, 4, 4, 4, 5, 5, 7, 9, -1, stddev, 300) → anomaly false, direction normal, center milli 5,000, spread milli 2,000, score hundredths 300 stddev: z of exactly -3.0 is not an anomaly either

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 detect_anomaly(history: &[i64], value: i64, method: &str, threshold_hundredths: i64) -> Anomaly
historyint[]recent values of the metric, at least 2, in any order
valueintthe new value to judge
methodAnomalyMethodstddev (mean and population standard deviation) or mad (median and MAD)
threshold_hundredthsintthe score above which it is an anomaly, x100: 300 = 3.0; 350 is the usual for mad
returnsAnomaly

The types it declares, generated into your project

// AnomalyMethod is a string in Rust, one of: "stddev", "mad".
// Parameters take it as &str and results hold it as String.

// AnomalyDirection is a string in Rust, one of: "high", "low", "normal".
// Parameters take it as &str and results hold it as String.

/// The verdict, with the numbers behind it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Anomaly {
    pub anomaly: bool,
    /// high or low when an anomaly, else normal
    pub direction: String,
    /// mean (half-up) or median, x1000
    pub center_milli: i64,
    /// population standard deviation or MAD, x1000, floored
    pub spread_milli: i64,
    /// |z| or |modified z|, x100, floored; null when the spread is 0
    pub score_hundredths: Option<i64>,
}

Your code names it in one line, in the file that uses it

fune!(monitor.anomaly@^1);  // then call detect_anomaly(…)
impl/rust.rs · 106 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

fn isqrt(n: i128) -> i128 {
    if n < 2 {
        return n;
    }
    let mut x = n;
    let mut y = (x + 1) / 2;
    while y < x {
        x = y;
        y = (x + n / x) / 2;
    }
    x
}

// Half-up with halves away from zero, as math.round-div rounds.
fn half_up(n: i128, d: i128) -> i128 {
    let q = (2 * n.abs() + d) / (2 * d);
    if n < 0 { -q } else { q }
}

fn median_twice(sorted: &[i128]) -> i128 {
    let m = sorted.len() / 2;
    if sorted.len() % 2 == 1 { 2 * sorted[m] } else { sorted[m - 1] + sorted[m] }
}

/// Whether `value` is an outlier against `history`, by z-score (`stddev`) or
/// by Iglewicz and Hoaglin's modified z-score (`mad`). Exact integer
/// arithmetic: the threshold comparison never depends on float rounding.
///
/// # Panics
/// Panics on a history under 2 values, a threshold under 1 or an unknown method.
pub fn detect_anomaly(history: &[i64], value: i64, method: &str, threshold_hundredths: i64) -> Anomaly {
    if history.len() < 2 {
        panic!("history must have at least 2 values, received {}", history.len());
    }
    if threshold_hundredths < 1 {
        panic!("thresholdHundredths must be at least 1, received {}", threshold_hundredths);
    }
    let xs: Vec<i128> = history.iter().map(|&x| x as i128).collect();
    let n = xs.len() as i128;
    let v = value as i128;
    let t = threshold_hundredths as i128;
    let (anomaly, signed, center, spread, score) = match method {
        "stddev" => {
            let s: i128 = xs.iter().sum();
            let ss: i128 = xs.iter().map(|x| x * x).sum();
            // n^2 * variance and n * (value - mean), both whole numbers.
            let q = n * ss - s * s;
            let signed = n * v - s;
            let center = half_up(s * 1000, n);
            let spread = isqrt(1_000_000 * q) / n;
            if q == 0 {
                (signed != 0, signed, center, spread, None)
            } else {
                let anomaly = 10000 * signed * signed > t * t * q;
                (anomaly, signed, center, spread, Some(isqrt(10000 * signed * signed / q)))
            }
        }
        "mad" => {
            // Twice the median and four times the MAD are whole numbers.
            let mut sorted = xs.clone();
            sorted.sort();
            let med2 = median_twice(&sorted);
            let mut devs: Vec<i128> = xs.iter().map(|x| (2 * x - med2).abs()).collect();
            devs.sort();
            let mad4 = median_twice(&devs);
            let signed = 2 * v - med2;
            if mad4 == 0 {
                (signed != 0, signed, med2 * 500, 0, None)
            } else {
                // |M| = 0.6745 * |2v - med2| / 2 / (mad4 / 4) = 1349 * |signed| / (1000 * mad4)
                let anomaly = 1349 * signed.abs() * 100 > t * 1000 * mad4;
                let score = 1349 * signed.abs() * 100 / (1000 * mad4);
                (anomaly, signed, med2 * 500, mad4 * 250, Some(score))
            }
        }
        other => panic!("unknown anomaly method: {}", other),
    };
    let direction = if !anomaly { "normal" } else if signed > 0 { "high" } else { "low" };
    Anomaly {
        anomaly,
        direction: direction.to_string(),
        center_milli: center as i64,
        spread_milli: spread as i64,
        score_hundredths: score.map(|s| s as i64),
    }
}

pub fn anomaly_to_value(a: &Anomaly) -> Value {
    Value::obj(vec![
        ("anomaly", Value::Bool(a.anomaly)),
        ("direction", Value::str(&a.direction)),
        ("centerMilli", Value::Int(a.center_milli)),
        ("spreadMilli", Value::Int(a.spread_milli)),
        ("scoreHundredths", match a.score_hundredths {
            Some(s) => Value::Int(s),
            None => Value::Null,
        }),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let history: Vec<i64> = args[0].as_arr().iter().map(|x| x.as_i64()).collect();
    anomaly_to_value(&detect_anomaly(&history, args[1].as_i64(), args[2].as_str(), args[3].as_i64()))
}

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 monitor.anomaly
Download for Rust monitor.anomaly-1.0.0-rust.fune · 14,475 bytes sha256 6f3f286a28f7378d8a9d293ac9e5e4144072ea65b8b6651119326dcb1ed65a47

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./monitor.anomaly-1.0.0-rust.fune, or fetch it from a terminal with fune pull monitor.anomaly@1.0.0:rust.

The whole function, every language, is one file too: monitor.anomaly-1.0.0.fune, 20,303 bytes, sha256 e8a9775aeb313d5da72d056bf055ca3a55eb60349a14667fe8ac2521749c8642. 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 monitor.anomaly

after — your function gets the result and the arguments, and returns the final result.

// fune: after monitor.anomaly

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 monitor.anomaly --steps.

// fune: step monitor.anomaly 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
stddev: mean 5, sd 2, value 12 is z 3.5, above 3.0 2, 4, 4, 4, 5, 5, 7, 9, 12, stddev, 300 → anomaly true, direction high, center milli 5,000, spread milli 2,000, score hundredths 350
stddev: z of exactly 3.0 against a threshold of 3.0 is not an anomaly 2, 4, 4, 4, 5, 5, 7, 9, 11, stddev, 300 → anomaly false, direction normal, center milli 5,000, spread milli 2,000, score hundredths 300
stddev: z of exactly -3.0 is not an anomaly either 2, 4, 4, 4, 5, 5, 7, 9, -1, stddev, 300 → anomaly false, direction normal, center milli 5,000, spread milli 2,000, score hundredths 300
stddev: a low outlier is low 2, 4, 4, 4, 5, 5, 7, 9, -2, stddev, 300 → anomaly true, direction low, center milli 5,000, spread milli 2,000, score hundredths 350
stddev: an irrational standard deviation, sqrt(20)/4, floored to 1118 1, 2, 3, 4, 10, stddev, 300 → anomaly true, direction high, center milli 2,500, spread milli 1,118, score hundredths 670
stddev: a mean of 2/3 rounds half up to 667 0, 1, 1, 1, stddev, 200 → anomaly false, direction normal, center milli 667, spread milli 471, score hundredths 70
stddev: a flat history has no spread, so any other value is an anomaly with no score 5, 5, 5, 6, stddev, 300 → anomaly true, direction high, center milli 5,000, spread milli 0, score hundredths —
stddev: a flat history and the same value is normal 5, 5, 5, 5, stddev, 300 → anomaly false, direction normal, center milli 5,000, spread milli 0, score hundredths —
stddev: values near 10^9 whose squares pass 2^53 are still exact 1,000,000,000, 1,000,000,002, 1,000,000,010, stddev, 300 → anomaly true, direction high, center milli 1,000,000,001,000, spread milli 1,000, score hundredths 900
stddev: one spike in the history inflates the sd and hides a new one 10, 12, 11, 13, 50, 30, stddev, 350 → anomaly false, direction normal, center milli 19,200, spread milli 15,432, score hundredths 69
Show the other 10 tests
CaseArgumentsExpected
mad: the same spike barely moves the median and MAD, so 30 is an anomaly 10, 12, 11, 13, 50, 30, mad, 350 → anomaly true, direction high, center milli 12,000, spread milli 1,000, score hundredths 1,214
mad: median 12, MAD 1, value 18 is M = 4.047 10, 12, 11, 13, 50, 18, mad, 350 → anomaly true, direction high, center milli 12,000, spread milli 1,000, score hundredths 404
mad: value 16 is M = 2.698, under 3.5 10, 12, 11, 13, 50, 16, mad, 350 → anomaly false, direction normal, center milli 12,000, spread milli 1,000, score hundredths 269
mad: an even history has a half-way median 2.5; value 8 is M = 3.70975 4, 1, 3, 2, 8, mad, 350 → anomaly true, direction high, center milli 2,500, spread milli 1,000, score hundredths 370
mad: a fractional MAD of 1.5 and a low outlier 1, 2, 4, 8, -3, mad, 250 → anomaly true, direction low, center milli 3,000, spread milli 1,500, score hundredths 269
mad: more than half the history equal gives MAD 0; a different value is an anomaly 7, 7, 7, 9, 8, mad, 350 → anomaly true, direction high, center milli 7,000, spread milli 0, score hundredths —
mad: MAD 0 and the median itself is normal 7, 7, 7, 9, 7, mad, 350 → anomaly false, direction normal, center milli 7,000, spread milli 0, score hundredths —
a history of one value is an error 5, 5, stddev, 300 → error: history must have at least 2 values, received 1
a threshold of zero is an error 1, 2, 5, mad, 0 → error: thresholdHundredths must be at least 1, received 0
an unknown method is an error 1, 2, 5, iqr, 300 → error: unknown anomaly method: iqr

More from the author

## Which to use

The mean and standard deviation are themselves dragged by the outliers you are looking for: one spike in the history inflates the standard deviation and hides the next spike. The median and MAD barely move. With history 10, 11, 12, 13, 50, a new value of 30 scores 0.69 by `stddev` (normal) but 12.14 by `mad` (an anomaly). Use `mad` for anything spiky, which is most operational metrics; `stddev` for smooth, roughly normal ones.

## Exact, not floating point

Everything is integer arithmetic (BigInt in TypeScript, i128 in Rust), so the three languages agree to the last digit and the comparison with the threshold is exact: a z of exactly 3.00 against a threshold of 300 is not an anomaly (`>`, not `>=`), in every language. The reported numbers are rounded as the manifest says: `centerMilli` half-up (halves away from zero), `spreadMilli` and `scoreHundredths` floored. The 0.6745 constant is used as exactly 6745/10000. In Rust, sums of squares must stay under 2^127: values up to about 10^15 with thousands of history points are fine.

## Zero spread

When the history is flat (standard deviation 0) or more than half of it is the same value (MAD 0), there is no scale to measure against: any value different from the center is an anomaly and equal is normal, and the score is null. Iglewicz and Hoaglin note this weakness of the MAD; feed it a longer or more varied history if it matters.

## Errors

- `history must have at least 2 values, received 1` - `thresholdHundredths must be at least 1, received 0` - `unknown anomaly method: iqr`

## Sources

- Boris Iglewicz and David C. Hoaglin (1993), *How to Detect and Handle Outliers*, ASQC Quality Press (ASQC Basic References in Quality Control, vol. 16): the modified z-score and the 3.5 threshold. Summarised by NIST/SEMATECH e-Handbook of Statistical Methods, 1.3.5.17 "Detection of Outliers", https://www.itl.nist.gov/div898/handbook/eda/section3/eda35h.htm

Files

PathBytes
README.md2,555
impl/python.py2,535
impl/rust.rs3,990
impl/typescript.ts3,075
vectors.json4,453