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 1667summariseLatency(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 = 1500summariseLatency(—, —, —)→ 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
| samplesUs | int?[] | round-trip times in microseconds, in the order sent; null for a probe that failed or timed out |
| returns | LatencySummary |
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";
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,824 |
| impl/python.py | 2,195 |
| impl/rust.rs | 3,833 |
| impl/typescript.ts | 2,107 |
| vectors.json | 3,589 |