Functional Weave
Code in TypeScript

net.latency-summary

Summarise probe results: sent, received, loss, min/avg/max, p95 and jitter, in whole microseconds.

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

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

What it does

Turns a run of probe results (ping replies, TCP connect times) into the figures `ping` prints at the end: sent, received, lost, loss, min/avg/max, plus a p95 and a jitter figure.

## Shape

For example

  • summariseLatency(10,000, 12,000, 11,000, 13,000) → sent 4, received 4, lost 0, loss basis points 0%, min us 10,000, avg us 11,500, max us 13,000, p95 us 13,000, jitter us 1,667 four replies: mean 11500, p95 is the largest of four (rank ceil 3.8 = 4), jitter (2000+1000+2000)/3 = 1666.67 rounds to 1667
  • summariseLatency(1,000, —, 3,000, 2,000) → sent 4, received 3, lost 1, loss basis points 25%, min us 1,000, avg us 2,000, max us 3,000, p95 us 3,000, jitter us 1,500 one of four lost is 25.00% loss; jitter skips the lost probe: (2000+1000)/2 = 1500
  • summariseLatency(—, —, —) → sent 3, received 0, lost 3, loss basis points 100%, min us —, avg us —, max us —, p95 us —, jitter us — every probe lost: 100% loss and no latency figures

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 summariseLatency(samplesUs: readonly (number | null)[]): LatencySummary
samplesUsint?[]round-trip times in microseconds, in the order sent; null for a probe that failed or timed out
returnsLatencySummary

The type it declares, generated into your project

/** What a run of probes says about a link, as ping prints it. */
export interface LatencySummary {
  readonly sent: number;
  readonly received: number;
  readonly lost: number;
  /** lost / sent, rounded half-up; 10000 = every probe lost */
  readonly lossBasisPoints: number;
  /** null when nothing was received */
  readonly minUs: number | null;
  /** mean, rounded half-up to a whole microsecond */
  readonly avgUs: number | null;
  readonly maxUs: number | null;
  /** nearest-rank 95th percentile, a sample that was really seen */
  readonly p95Us: number | null;
  /** mean absolute difference of consecutive received samples, half-up; null under two */
  readonly jitterUs: number | null;
}

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

import { summariseLatency } from "#fune/net.latency-summary@^1";
impl/typescript.ts · 58 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.

import { roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { percentile } from "./stats_percentile.ts";  ← from stats.percentile ^2.0.0 · built alongside by fune
import { type LatencySummary } from "./net_latency_summary_types.ts";

/**
 * Summarise a run of probes the way ping does, in whole microseconds.
 *
 * Integer microseconds rather than float milliseconds, so every language
 * gives the same answer and a vector can compare exactly. A lost probe counts
 * towards loss and is skipped (not a break) when measuring jitter.
 */
export function summariseLatency(samplesUs: readonly (number | null)[]): LatencySummary {
  if (!Array.isArray(samplesUs)) throw new TypeError("samplesUs must be a list");
  if (samplesUs.length === 0) throw new RangeError("samplesUs must not be empty");
  const got: number[] = [];
  for (const s of samplesUs) {
    if (s === null || s === undefined) continue;
    if (typeof s !== "number" || !Number.isInteger(s)) {
      throw new TypeError(`each sample must be a whole number of microseconds or null, received ${s}`);
    }
    if (s < 0) throw new RangeError(`samples must not be negative, received ${s}`);
    got.push(s);
  }
  const sent = samplesUs.length;
  const received = got.length;
  const lost = sent - received;
  const lossBasisPoints = roundDiv(lost * 10000, sent, "half-up");
  if (received === 0) {
    return { sent, received, lost, lossBasisPoints, minUs: null, avgUs: null, maxUs: null, p95Us: null, jitterUs: null };
  }
  let min = got[0];
  let max = got[0];
  let sum = 0;
  for (const v of got) {
    if (v < min) min = v;
    if (v > max) max = v;
    sum += v;
  }
  let jitterUs: number | null = null;
  if (received >= 2) {
    let diffs = 0;
    for (let i = 1; i < received; i++) diffs += Math.abs(got[i] - got[i - 1]);
    jitterUs = roundDiv(diffs, received - 1, "half-up");
  }
  // Nearest-rank returns a sample that was really seen, so it is a whole number.
  const p95Us = percentile(got, 95, "nearest-rank", 0);
  return {
    sent,
    received,
    lost,
    lossBasisPoints,
    minUs: min,
    avgUs: roundDiv(sum, received, "half-up"),
    maxUs: max,
    p95Us,
    jitterUs,
  };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, 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 net.latency-summary
Download for TypeScript net.latency-summary-1.0.0-typescript.fune · 10,699 bytes sha256 a7cdd01c480c682c30c72dff8f209e2783050ab5c8aee229a316edb269010133

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./net.latency-summary-1.0.0-typescript.fune, or fetch it from a terminal with fune pull net.latency-summary@1.0.0:typescript.

The whole function, every language, is one file too: net.latency-summary-1.0.0.fune, 16,995 bytes, sha256 c5e4c99d628f4ef623dfefae12ed3b64b1ec4e9e410a83d09c0d2247bd5d9cb0. 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 net.latency-summary

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

// fune: after net.latency-summary

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace math.round-div in net.latency-summary
// fune: replace stats.percentile in net.latency-summary

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 net.latency-summary --steps.

// fune: step net.latency-summary 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
four replies: mean 11500, p95 is the largest of four (rank ceil 3.8 = 4), jitter (2000+1000+2000)/3 = 1666.67 rounds to 1667 10,000, 12,000, 11,000, 13,000 → sent 4, received 4, lost 0, loss basis points 0%, min us 10,000, avg us 11,500, max us 13,000, p95 us 13,000, jitter us 1,667
one of four lost is 25.00% loss; jitter skips the lost probe: (2000+1000)/2 = 1500 1,000, —, 3,000, 2,000 → sent 4, received 3, lost 1, loss basis points 25%, min us 1,000, avg us 2,000, max us 3,000, p95 us 3,000, jitter us 1,500
every probe lost: 100% loss and no latency figures —, —, — → sent 3, received 0, lost 3, loss basis points 100%, min us —, avg us —, max us —, p95 us —, jitter us —
a single reply has no jitter 500 → sent 1, received 1, lost 0, loss basis points 0%, min us 500, avg us 500, max us 500, p95 us 500, jitter us —
one of three lost is 3333.33 bp, rounded down to 3333 100, —, 200 → sent 3, received 2, lost 1, loss basis points 33.33%, min us 100, avg us 150, max us 200, p95 us 200, jitter us 100
two of three lost is 6666.67 bp, rounded up to 6667 —, 700, — → sent 3, received 1, lost 2, loss basis points 66.67%, min us 700, avg us 700, max us 700, p95 us 700, jitter us —
mean 1.5 rounds half-up to 2 1, 2 → sent 2, received 2, lost 0, loss basis points 0%, min us 1, avg us 2, max us 2, p95 us 2, jitter us 1
p95 of 1000..20000 is the 19th value, 19000, not the maximum 1,000, 2,000, 3,000, 4,000, 5,000, 6,000, 7,000, 8,000, 9,000, 10,000, 11,000, 12,000, 13,000, 14,000, 15,000, 16,000, 17,000, 18,000, 19,000, 20,000 → sent 20, received 20, lost 0, loss basis points 0%, min us 1,000, avg us 10,500, max us 20,000, p95 us 19,000, jitter us 1,000
jitter follows send order, not sorted order: (20+40+30)/3 = 30; mean 27.5 rounds to 28 30, 10, 50, 20 → sent 4, received 4, lost 0, loss basis points 0%, min us 10, avg us 28, max us 50, p95 us 50, jitter us 30
zero-microsecond replies are replies, not losses 0, 0 → sent 2, received 2, lost 0, loss basis points 0%, min us 0, avg us 0, max us 0, p95 us 0, jitter us 0
Show the other 6 tests
CaseArgumentsExpected
one of seven lost is 1428.57 bp, rounded to 1429 —, 5, 5, 5, 5, 5, 5 → sent 7, received 6, lost 1, loss basis points 14.29%, min us 5, avg us 5, max us 5, p95 us 5, jitter us 0
jitter 0.5 rounds half-up to 1; mean 0.67 rounds to 1 0, 1, 1 → sent 3, received 3, lost 0, loss basis points 0%, min us 0, avg us 1, max us 1, p95 us 1, jitter us 1
an empty run is an error → error: samplesUs must not be empty
a negative round-trip time is an error 5, -1 → error: samples must not be negative
a fractional sample is refused, not truncated 1.5 → error: each sample must be a whole number of microseconds or null
a text sample is refused 5 → error: each sample must be a whole number of microseconds or null

More from the author

- **Input is whole microseconds, `null` for a lost probe**, in the order the probes were sent. Integers keep all three languages identical and let the vectors compare exactly; a caller timing with a monotonic clock converts once (`elapsed.as_micros()`). - **Loss** is `lost / sent` in basis points, rounded half-up (`math.round-div`): 1 of 3 lost is 3333, 2 of 3 is 6667, all lost is 10000. - **avg** is the mean of the received samples, rounded half-up to a whole microsecond. - **p95** is the nearest-rank 95th percentile (`stats.percentile`, `nearest-rank`): the smallest sample with at least 95% of samples at or below it. It is always a sample that was really seen; for 20 samples it is the 19th smallest, not the maximum. - **jitter** is the mean absolute difference between consecutive *received* samples, in send order, rounded half-up. A lost probe is skipped, not a break, so `[1000, null, 3000, 2000]` compares 1000 with 3000 and 3000 with 2000. This is the simple "mean deviation of successive round trips", not RFC 3550's exponentially smoothed interarrival jitter (which needs one-way transit times and weights recent packets); it is `null` with fewer than two replies. - With no replies, min/avg/max/p95/jitter are all `null` and loss is 10000.

## Errors

An empty list (`samplesUs must not be empty`), a negative sample (`samples must not be negative`), and a sample that is not a whole number or null (`each sample must be a whole number of microseconds or null`).

Rust callers building on this can use `latency_summary_to_value` and `latency_summary_from_value`.

Files

PathBytes
README.md1,824
impl/python.py2,195
impl/rust.rs3,833
impl/typescript.ts2,107
vectors.json3,589