Functional Weave
Code in Rust

monitor.rollup

Aggregate a metric series into fixed time buckets (count, min, max, avg, sum, last), empty buckets included.

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

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

What it does

Turns a raw metric series into one row per fixed time bucket (a minute, an hour, a day) with the count, min, max, average, sum and last value: the table behind a latency or throughput chart, or a coarser copy kept for longer retention. `charts.downsample` is the other tool: it picks points that keep a line's shape (LTTB); this one aggregates.

## Buckets

For example

  • rollup(samples ×5, 0, 180, 60) → ×3 three one-minute buckets
  • rollup(samples ×2, 0, 180, 60) → ×3 an empty bucket in the middle is kept, so a chart shows the gap
  • rollup(samples ×3, 0, 100, 60) → ×2 the last bucket is clipped to to

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 rollup(samples: &[MetricSample], from: i64, to: i64, bucket_seconds: i64) -> Vec<Bucket>
samplesMetricSample[]in strictly ascending time order
fromintstart of the first bucket, Unix seconds; buckets are aligned to it
tointend of the last bucket (excluded); the last bucket is clipped to it
bucket_secondsintbucket width, at least 1; at most 10000 buckets
returnsBucket[]every bucket from `from` to `to`, oldest first, empty ones included

The type it declares, generated into your project

/// The samples with start <= at < end, aggregated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Bucket {
    /// Unix seconds, included
    pub start: i64,
    /// Unix seconds, excluded
    pub end: i64,
    pub count: i64,
    /// null when the bucket is empty
    pub min: Option<i64>,
    /// null when the bucket is empty
    pub max: Option<i64>,
    /// sum / count, half-up (halves away from zero); null when empty
    pub avg: Option<i64>,
    /// 0 when empty
    pub sum: i64,
    /// value of the latest sample; null when empty
    pub last: Option<i64>,
}

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

fune!(monitor.rollup@^1);  // then call rollup(…)
impl/rust.rs · 80 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::monitor_series_window::{samples_from_value, series_window, MetricSample};  ← from monitor.series-window ^1.0.0 · built alongside by fune

const MAX_BUCKETS: i64 = 10000;

/// One row per bucket from `from` to `to`, empty buckets included so a chart
/// shows gaps as gaps. Buckets are aligned to `from`; the last is clipped to `to`.
///
/// # Panics
/// Panics on a bucket width under 1, more than 10000 buckets, `from` after
/// `to`, or misordered samples.
pub fn rollup(samples: &[MetricSample], from: i64, to: i64, bucket_seconds: i64) -> Vec<Bucket> {
    if bucket_seconds < 1 {
        panic!("bucketSeconds must be at least 1, received {}", bucket_seconds);
    }
    let inside = series_window(samples, from, to);
    let n = (to - from + bucket_seconds - 1) / bucket_seconds;
    if n > MAX_BUCKETS {
        panic!("too many buckets: {}, at most {}", n, MAX_BUCKETS);
    }
    let mut out = Vec::new();
    let mut j = 0usize;
    for i in 0..n {
        let start = from + i * bucket_seconds;
        let end = (start + bucket_seconds).min(to);
        let mut count = 0i64;
        let mut sum = 0i64;
        let mut min: Option<i64> = None;
        let mut max: Option<i64> = None;
        let mut last: Option<i64> = None;
        while j < inside.len() && inside[j].at < end {
            let v = inside[j].value;
            count += 1;
            sum += v;
            min = Some(min.map_or(v, |m| m.min(v)));
            max = Some(max.map_or(v, |m| m.max(v)));
            last = Some(v);
            j += 1;
        }
        let avg = if count == 0 { None } else { Some(round_div(sum, count, "half-up")) };
        out.push(Bucket { start, end, count, min, max, avg, sum, last });
    }
    out
}

fn opt(v: Option<i64>) -> Value {
    match v {
        Some(i) => Value::Int(i),
        None => Value::Null,
    }
}

pub fn bucket_to_value(b: &Bucket) -> Value {
    Value::obj(vec![
        ("start", Value::Int(b.start)),
        ("end", Value::Int(b.end)),
        ("count", Value::Int(b.count)),
        ("min", opt(b.min)),
        ("max", opt(b.max)),
        ("avg", opt(b.avg)),
        ("sum", Value::Int(b.sum)),
        ("last", opt(b.last)),
    ])
}

fn whole(v: &Value, message: &str) -> i64 {
    match v {
        Value::Int(i) => *i,
        _ => panic!("{}", message),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    let samples = samples_from_value(&args[0]);
    let from = whole(&args[1], "from and to must be whole seconds");
    let to = whole(&args[2], "from and to must be whole seconds");
    let bucket = whole(&args[3], "bucketSeconds must be a whole number of seconds");
    Value::Arr(rollup(&samples, from, to, bucket).iter().map(bucket_to_value).collect())
}

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 monitor.rollup
Download for Rust monitor.rollup-1.0.0-rust.fune · 12,197 bytes sha256 5e3034c15fec251583abde5133f6da417dec1234c87cb651ff0ed841748861a6

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./monitor.rollup-1.0.0-rust.fune, or fetch it from a terminal with fune pull monitor.rollup@1.0.0:rust.

The whole function, every language, is one file too: monitor.rollup-1.0.0.fune, 15,684 bytes, sha256 24f1815aeec79e93ba09881a92cd6a2eea649de96822ffbc1de2eda986c78e0c. 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 monitor.rollup

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

// fune: after monitor.rollup

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 monitor.rollup
// fune: replace monitor.series-window in monitor.rollup

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 monitor.rollup --steps.

// fune: step monitor.rollup 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
three one-minute buckets samples ×5, 0, 180, 60 → ×3
an empty bucket in the middle is kept, so a chart shows the gap samples ×2, 0, 180, 60 → ×3
the last bucket is clipped to to samples ×3, 0, 100, 60 → ×2
a sample exactly at to is outside the window samples ×2, 0, 120, 60 → ×2
averages round half up, halves away from zero: 1.5 is 2 and -1.5 is -2 samples ×4, 0, 20, 10 → ×2
buckets are aligned to from, and samples outside the window are ignored samples ×5, 45, 165, 60 → ×2
last is the latest sample, not the largest samples ×2, 0, 10, 10 → ×1
a bucket wider than the window is one clipped bucket samples ×1, 0, 30, 60 → ×1
an empty window has no buckets samples ×1, 100, 100, 60 →
no samples gives empty buckets, not no buckets , 0, 120, 60 → ×2
Show the other 5 tests
CaseArgumentsExpected
a zero-width bucket is an error , 0, 60, 0 → error: bucketSeconds must be at least 1, received 0
a fractional bucket width is an error , 0, 60, 1.5 → error: bucketSeconds must be a whole number of seconds
more than 10000 buckets is an error , 0, 10,001, 1 → error: too many buckets: 10001, at most 10000
from after to is an error , 200, 100, 60 → error: from must not be after to: 200 > 100
misordered samples are an error samples ×2, 0, 60, 60 → error: samples must be in strictly ascending time order: 5 follows 10

More from the author

Buckets start at `from` and step by `bucketSeconds`, so they are aligned to `from`, not to the epoch: pass a `from` on a minute or hour boundary to get round buckets. Each is half-open, `start <= at < end`, like `monitor.series-window`, so no sample lands in two buckets. The last bucket is clipped at `to` when the window is not a whole number of buckets.

Empty buckets are returned too (count 0, sum 0, the rest null). A chart then shows a gap where data is missing instead of drawing a line straight across it, and every result for the same window has the same number of rows.

`from == to` gives no buckets. More than 10000 buckets is refused: that is almost always a bucket size in the wrong unit (seconds for minutes).

## Average

`avg` is `sum / count` rounded half up with `math.round-div`, whose half-up rounds halves away from zero: -1 and -2 average to -2, as 1 and 2 average to 2 (`Math.round(-1.5)` in JavaScript gives -1). Keep the `sum` and `count` if you need to combine buckets later: averaging averages is wrong when counts differ.

## Errors

- `bucketSeconds must be at least 1, received 0` - `bucketSeconds must be a whole number of seconds` - `too many buckets: 10001, at most 10000` - from `monitor.series-window`: `from must not be after to: 200 > 100`, `samples must be in strictly ascending time order: 5 follows 10`, `from and to must be whole seconds`

Files

PathBytes
README.md1,762
impl/python.py1,708
impl/rust.rs2,742
impl/typescript.ts1,640
vectors.json4,000