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
detectAnomaly(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.0detectAnomaly(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 anomalydetectAnomaly(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.
export function detectAnomaly(history: readonly number[], value: number, method: AnomalyMethod, thresholdHundredths: number): 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) |
| thresholdHundredths | 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
export type AnomalyMethod = "stddev" | "mad";
export type AnomalyDirection = "high" | "low" | "normal";
/** The verdict, with the numbers behind it. */
export interface Anomaly {
readonly anomaly: boolean;
/** high or low when an anomaly, else normal */
readonly direction: AnomalyDirection;
/** mean (half-up) or median, x1000 */
readonly centerMilli: number;
/** population standard deviation or MAD, x1000, floored */
readonly spreadMilli: number;
/** |z| or |modified z|, x100, floored; null when the spread is 0 */
readonly scoreHundredths: number | null;
}
Your code names it in one line, in the file that uses it
import { detectAnomaly } from "#fune/monitor.anomaly@^1";
import { type Anomaly, type AnomalyMethod } from "./monitor_anomaly_types.ts";
function isqrt(n: bigint): bigint {
if (n < 2n) return n;
let x = n;
let y = (x + 1n) / 2n;
while (y < x) {
x = y;
y = (x + n / x) / 2n;
}
return x;
}
const abs = (n: bigint): bigint => (n < 0n ? -n : n);
// Half-up with halves away from zero, as math.round-div rounds.
function halfUp(n: bigint, d: bigint): bigint {
const q = (2n * abs(n) + d) / (2n * d);
return n < 0n ? -q : q;
}
function medianTwice(sorted: readonly bigint[]): bigint {
const m = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1 ? 2n * sorted[m] : sorted[m - 1] + sorted[m];
}
const byValue = (a: bigint, b: bigint): number => (a < b ? -1 : a > b ? 1 : 0);
/**
* 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 floating-point rounding.
*/
export function detectAnomaly(history: readonly number[], value: number, method: AnomalyMethod, thresholdHundredths: number): Anomaly {
if (history.length < 2) throw new RangeError(`history must have at least 2 values, received ${history.length}`);
if (thresholdHundredths < 1) throw new RangeError(`thresholdHundredths must be at least 1, received ${thresholdHundredths}`);
const xs = history.map((x) => BigInt(x));
const v = BigInt(value);
const t = BigInt(thresholdHundredths);
const n = BigInt(xs.length);
let anomaly: boolean;
let signed: bigint;
let center: bigint;
let spread: bigint;
let score: bigint | null;
if (method === "stddev") {
let s = 0n;
let ss = 0n;
for (const x of xs) {
s += x;
ss += x * x;
}
// n^2 * variance and n * (value - mean), both whole numbers.
const q = n * ss - s * s;
signed = n * v - s;
center = halfUp(s * 1000n, n);
spread = isqrt(1000000n * q) / n;
if (q === 0n) {
anomaly = signed !== 0n;
score = null;
} else {
anomaly = 10000n * signed * signed > t * t * q;
score = isqrt((10000n * signed * signed) / q);
}
} else if (method === "mad") {
// Twice the median and four times the MAD are whole numbers.
const med2 = medianTwice([...xs].sort(byValue));
const mad4 = medianTwice(xs.map((x) => abs(2n * x - med2)).sort(byValue));
signed = 2n * v - med2;
center = med2 * 500n;
spread = mad4 * 250n;
if (mad4 === 0n) {
anomaly = signed !== 0n;
score = null;
} else {
// |M| = 0.6745 * |2v - med2| / 2 / (mad4 / 4) = 1349 * |signed| / (1000 * mad4)
anomaly = 1349n * abs(signed) * 100n > t * 1000n * mad4;
score = (1349n * abs(signed) * 100n) / (1000n * mad4);
}
} else {
throw new RangeError(`unknown anomaly method: ${method as string}`);
}
return {
anomaly,
direction: !anomaly ? "normal" : signed > 0n ? "high" : "low",
centerMilli: Number(center),
spreadMilli: Number(spread),
scoreHundredths: score === null ? null : Number(score),
};
}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 monitor.anomaly
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./monitor.anomaly-1.0.0-typescript.fune, or fetch it from a terminal with fune pull monitor.anomaly@1.0.0:typescript.
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 |