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.
pub fn summarise_latency(samples_us: &[Option<i64>]) -> 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
/// What a run of probes says about a link, as ping prints it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LatencySummary {
pub sent: i64,
pub received: i64,
pub lost: i64,
/// lost / sent, rounded half-up; 10000 = every probe lost
pub loss_basis_points: i64,
/// null when nothing was received
pub min_us: Option<i64>,
/// mean, rounded half-up to a whole microsecond
pub avg_us: Option<i64>,
pub max_us: Option<i64>,
/// nearest-rank 95th percentile, a sample that was really seen
pub p95_us: Option<i64>,
/// mean absolute difference of consecutive received samples, half-up; null under two
pub jitter_us: Option<i64>,
}
Your code names it in one line, in the file that uses it
fune!(net.latency-summary@^1); // then call summarise_latency(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
use super::stats_percentile::percentile; ← from stats.percentile ^2.0.0 · built alongside by fune
/// 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.
///
/// # Panics
/// Panics on an empty list or a negative sample.
pub fn summarise_latency(samples_us: &[Option<i64>]) -> LatencySummary {
if samples_us.is_empty() {
panic!("samplesUs must not be empty");
}
let mut got: Vec<i64> = Vec::new();
for s in samples_us.iter().flatten() {
if *s < 0 {
panic!("samples must not be negative, received {}", s);
}
got.push(*s);
}
let sent = samples_us.len() as i64;
let received = got.len() as i64;
let lost = sent - received;
let loss = round_div(lost * 10000, sent, "half-up");
if received == 0 {
return LatencySummary {
sent,
received,
lost,
loss_basis_points: loss,
min_us: None,
avg_us: None,
max_us: None,
p95_us: None,
jitter_us: None,
};
}
let jitter = if received >= 2 {
let diffs: i64 = got.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
Some(round_div(diffs, received - 1, "half-up"))
} else {
None
};
// Nearest-rank returns a sample that was really seen, so it is a whole number.
let floats: Vec<f64> = got.iter().map(|v| *v as f64).collect();
let p95 = percentile(&floats, 95.0, "nearest-rank", 0) as i64;
LatencySummary {
sent,
received,
lost,
loss_basis_points: loss,
min_us: got.iter().min().copied(),
avg_us: Some(round_div(got.iter().sum(), received, "half-up")),
max_us: got.iter().max().copied(),
p95_us: Some(p95),
jitter_us: jitter,
}
}
fn opt(v: Option<i64>) -> Value {
match v {
Some(n) => Value::Int(n),
None => Value::Null,
}
}
pub fn latency_summary_to_value(s: &LatencySummary) -> Value {
Value::obj(vec![
("sent", Value::Int(s.sent)),
("received", Value::Int(s.received)),
("lost", Value::Int(s.lost)),
("lossBasisPoints", Value::Int(s.loss_basis_points)),
("minUs", opt(s.min_us)),
("avgUs", opt(s.avg_us)),
("maxUs", opt(s.max_us)),
("p95Us", opt(s.p95_us)),
("jitterUs", opt(s.jitter_us)),
])
}
/// Read a LatencySummary back from JSON, for capabilities that take one.
pub fn latency_summary_from_value(v: &Value) -> LatencySummary {
let o = |k: &str| {
let x = v.get(k);
if x.is_null() {
None
} else {
Some(x.as_i64())
}
};
LatencySummary {
sent: v.get("sent").as_i64(),
received: v.get("received").as_i64(),
lost: v.get("lost").as_i64(),
loss_basis_points: v.get("lossBasisPoints").as_i64(),
min_us: o("minUs"),
avg_us: o("avgUs"),
max_us: o("maxUs"),
p95_us: o("p95Us"),
jitter_us: o("jitterUs"),
}
}
pub fn fune_vector(args: &[Value]) -> Value {
let samples: Vec<Option<i64>> = args[0]
.as_arr()
.iter()
.map(|v| match v {
Value::Null => None,
Value::Int(n) => Some(*n),
Value::Float(f) if f.fract() == 0.0 && f.is_finite() => Some(*f as i64),
other => panic!(
"each sample must be a whole number of microseconds or null, received {}",
other
),
})
.collect();
latency_summary_to_value(&summarise_latency(&samples))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./net.latency-summary-1.0.0-rust.fune, or fetch it from a terminal with fune pull net.latency-summary@1.0.0:rust.
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 |