Functional Weave
Code in TypeScript

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.0
  • detectAnomaly(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
  • detectAnomaly(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
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)
thresholdHundredthsintthe 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

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";
impl/typescript.ts · 90 lines · open · raw
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
Download for TypeScript monitor.anomaly-1.0.0-typescript.fune · 13,542 bytes sha256 867639219a171ccfba57958b43775bde86ee6c47f3e8fe69a72f60b5ff2e34df

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.

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