Functional Weave
Code in Rust

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 1667
  • summarise_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 = 1500
  • summarise_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_usint?[]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.
#[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(…)
impl/rust.rs · 122 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.

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
Download for Rust net.latency-summary-1.0.0-rust.fune · 12,507 bytes sha256 3bc5721e3372041d67bfcb38e8743f1ab194ed291f701de47a70b4266e7e238c

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.

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