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.0detect_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 anomalydetect_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
| history | int[] | recent values of the metric, at least 2, in any order |
| value | int | the new value to judge |
| method | AnomalyMethod | stddev (mean and population standard deviation) or mad (median and MAD) |
| threshold_hundredths | int | the score above which it is an anomaly, x100: 300 = 3.0; 350 is the usual for mad |
| returns | Anomaly |
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(…)
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 2,555 |
| impl/python.py | 2,535 |
| impl/rust.rs | 3,990 |
| impl/typescript.ts | 3,075 |
| vectors.json | 4,453 |