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
summarise_latency(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 1667summarise_latency(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 = 1500summarise_latency(—, —, —)→ 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.
def summarise_latency(samples_us: Sequence[Optional[int]]) -> LatencySummary
| samples_us | 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
@dataclass(frozen=True)
class LatencySummary:
"""What a run of probes says about a link, as ping prints it."""
sent: int
received: int
lost: int
#: lost / sent, rounded half-up; 10000 = every probe lost
loss_basis_points: int
#: null when nothing was received
min_us: Optional[int]
#: mean, rounded half-up to a whole microsecond
avg_us: Optional[int]
max_us: Optional[int]
#: nearest-rank 95th percentile, a sample that was really seen
p95_us: Optional[int]
#: mean absolute difference of consecutive received samples, half-up; null under two
jitter_us: Optional[int]
Your code names it in one line, in the file that uses it
from fune.net.latency_summary import summarise_latency # 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.
from typing import List, Optional, Sequence
from .math_round_div import round_div ← from math.round-div ^1.0.0 · built alongside by fune
from .stats_percentile import percentile ← from stats.percentile ^2.0.0 · built alongside by fune
from .net_latency_summary_types import LatencySummary
def summarise_latency(samples_us: Sequence[Optional[int]]) -> LatencySummary:
"""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.
"""
if isinstance(samples_us, (str, bytes)) or not isinstance(samples_us, (list, tuple)):
raise TypeError("samplesUs must be a list")
if len(samples_us) == 0:
raise ValueError("samplesUs must not be empty")
got: List[int] = []
for s in samples_us:
if s is None:
continue
if isinstance(s, bool) or not (isinstance(s, int) or (isinstance(s, float) and s.is_integer())):
raise TypeError("each sample must be a whole number of microseconds or null, received %r" % (s,))
s = int(s)
if s < 0:
raise ValueError("samples must not be negative, received %d" % s)
got.append(s)
sent = len(samples_us)
received = len(got)
lost = sent - received
loss = round_div(lost * 10000, sent, "half-up")
if received == 0:
return LatencySummary(sent=sent, received=received, lost=lost, loss_basis_points=loss,
min_us=None, avg_us=None, max_us=None, p95_us=None, jitter_us=None)
jitter = None
if received >= 2:
diffs = sum(abs(got[i] - got[i - 1]) for i in range(1, received))
jitter = round_div(diffs, received - 1, "half-up")
# Nearest-rank returns a sample that was really seen, so it is a whole number.
p95 = int(percentile([float(v) for v in got], 95, "nearest-rank", 0))
return LatencySummary(
sent=sent,
received=received,
lost=lost,
loss_basis_points=loss,
min_us=min(got),
avg_us=round_div(sum(got), received, "half-up"),
max_us=max(got),
p95_us=p95,
jitter_us=jitter,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, 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 net.latency-summary
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./net.latency-summary-1.0.0-python.fune, or fetch it from a terminal with fune pull net.latency-summary@1.0.0:python.
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 |