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.
def detect_anomaly(history: Sequence[int], value: int, method: AnomalyMethod, threshold_hundredths: int) -> 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 = Literal["stddev", "mad"]
AnomalyDirection = Literal["high", "low", "normal"]
@dataclass(frozen=True)
class Anomaly:
"""The verdict, with the numbers behind it."""
anomaly: bool
#: high or low when an anomaly, else normal
direction: AnomalyDirection
#: mean (half-up) or median, x1000
center_milli: int
#: population standard deviation or MAD, x1000, floored
spread_milli: int
#: |z| or |modified z|, x100, floored; null when the spread is 0
score_hundredths: Optional[int]
Your code names it in one line, in the file that uses it
from fune.monitor.anomaly import detect_anomaly # monitor.anomaly@^1
import math
from typing import List, Optional, Sequence
from .monitor_anomaly_types import Anomaly, AnomalyMethod
def _half_up(n: int, d: int) -> int:
# Half-up with halves away from zero, as math.round-div rounds.
q = (2 * abs(n) + d) // (2 * d)
return -q if n < 0 else q
def _median_twice(ordered: List[int]) -> int:
m = len(ordered) // 2
return 2 * ordered[m] if len(ordered) % 2 == 1 else ordered[m - 1] + ordered[m]
def detect_anomaly(history: Sequence[int], value: int, method: AnomalyMethod, threshold_hundredths: int) -> Anomaly:
"""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."""
if len(history) < 2:
raise ValueError("history must have at least 2 values, received %d" % len(history))
if threshold_hundredths < 1:
raise ValueError("thresholdHundredths must be at least 1, received %d" % threshold_hundredths)
n = len(history)
t = threshold_hundredths
score: Optional[int]
if method == "stddev":
s = sum(history)
ss = sum(x * x for x in history)
# n^2 * variance and n * (value - mean), both whole numbers.
q = n * ss - s * s
signed = n * value - s
center = _half_up(s * 1000, n)
spread = math.isqrt(1000000 * q) // n
if q == 0:
anomaly = signed != 0
score = None
else:
anomaly = 10000 * signed * signed > t * t * q
score = math.isqrt((10000 * signed * signed) // q)
elif method == "mad":
# Twice the median and four times the MAD are whole numbers.
med2 = _median_twice(sorted(history))
mad4 = _median_twice(sorted(abs(2 * x - med2) for x in history))
signed = 2 * value - med2
center = med2 * 500
spread = mad4 * 250
if mad4 == 0:
anomaly = signed != 0
score = None
else:
# |M| = 0.6745 * |2v - med2| / 2 / (mad4 / 4) = 1349 * |signed| / (1000 * mad4)
anomaly = 1349 * abs(signed) * 100 > t * 1000 * mad4
score = (1349 * abs(signed) * 100) // (1000 * mad4)
else:
raise ValueError("unknown anomaly method: %s" % method)
direction = "normal" if not anomaly else ("high" if signed > 0 else "low")
return Anomaly(anomaly=anomaly, direction=direction, center_milli=center, spread_milli=spread, score_hundredths=score)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python 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 Python implementation. Install it without the registry with fune add ./monitor.anomaly-1.0.0-python.fune, or fetch it from a terminal with fune pull monitor.anomaly@1.0.0:python.
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 |