# Data and performance ## The sample stores All series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which describes the sample-indexing contract; a **uniform-grid** series additionally has sample `i` at `x = x0() + i * dx()` (`ArraySeries`, `StreamSeries`), while **`XYSeries`** carries its own sorted, ascending x array for data that is not on a uniform grid. ```cpp class Series { public: virtual u64 size() const; virtual f64 x0() const; // x of sample 0 (the oldest retained sample) virtual f64 dx() const; // 0 for XYSeries, which owns its x array virtual Range x_bounds() const; virtual Range y_bounds() const; virtual const MinMaxPyramid& pyramid() const; virtual bool streaming() const noexcept; // default false virtual f64 x_at(u64 i) const; // x0() + i*dx(), or x()[i] virtual bool neighbours(f64 x, u64& i0, u64& i1) const; // bracketing samples virtual u64 snap_index(f64 x) const; // nearest sample virtual void gather(u32 level, u64 begin, u64 end, std::vector& out_xy, GatherMode mode = GatherMode::Envelope) const; Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end) f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end) f64 sumsq(u64 begin, u64 end) const; // exact sum of squares }; ``` Indices are the series' **own window**: index 0 is the oldest sample it currently holds, so `size()`, `x0()`, `reduce()`, `gather()` and `pyramid()` are all in the same coordinates. **`ArraySeries`** (`array_series.hpp`) owns a `std::vector` of y samples plus an `x0`/`dx` grid. Construct with `ArraySeries(std::vector y, f64 x0, f64 dx)`. The vector is taken by value and owned, so the samples cannot change behind the series' back; the store changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild), `commit()`, which rebuilds the pyramid and cached bounds from the current `y`, or `push(y)`, which appends one sample incrementally — the pyramid folds it in amortised O(1) and the bounds extend in place, NaN-aware, instead of rebuilding. **`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`. `capacity` is rounded up to a power of two by the ring, `size()` saturates at that rounded `capacity()` (a `StreamSeries(100)` retains up to 128 samples), and the pyramid is reserved for the rounded value up front. `freeze()` copies the window published by the last `acquire()` into a fresh `ArraySeries` (consumer thread only): a stable snapshot that no later production can move. ## Non-finite samples The library-wide NaN policy: **a non-finite sample is excluded from every aggregate** — min, max, sum, sum of squares and the pyramid's per-bucket finite count — and from the cached bounds. A window with no finite samples at all aggregates to a **zero-span `Range` / zero sums**, never to NaN. The policy is applied everywhere an aggregate is built: `MinMaxPyramid::append` (a NaN occupies its absolute position, so bucket boundaries stay positional, but folds nothing), the edge folds in `Series::reduce`, `sum` and `sumsq`, `ArraySeries::commit` and `push` bounds, and `StreamSeries::acquire` bounds. A bucket whose samples were all non-finite is stored as an *empty* aggregate (count 0, zero sums) that every reader skips — it is absent, not zero-valued. `gather()` follows suit: Envelope and Mean emit nothing for such a bucket, and Nearest draws its centre sample raw. Hosts that want gaps in the data should draw them with NaN samples and will get exactly that: excluded from statistics, present in raw geometry. ## Gather x is relative, and what each mode emits **`Series::gather` emits x relative to `x0()`** — exactly `i * dx` for a uniform series, `x_at(i) - x0()` for `XYSeries` — never the absolute coordinate. y is unchanged. The renderer reconstructs absolute data x in f64 (`x0() + emitted`) before transforming, so no precision is lost at the f32 narrowing: the staged value is O(visible pixels), not O(epoch). (Staging an absolute epoch x as f32 would quantise it to a grid coarser than the pixel — f32 carries 24 bits, about 1 s at 1.7e9 — while the relative value is small and exact.) The emitted geometry always spans `[begin, end)` exactly and never reaches outside it: at level ≥ 1 the bucket edges are selected ceil/floor (`first` = first bucket starting at or after `begin`, `last` = one past the last bucket ending at or before `end`) and the two partial edge spans are drawn from raw samples, which is what keeps an unaligned window envelope-exact (previously the buckets straddling the range leaked samples from outside it into the geometry). `level` is clamped to `kMaxPyramidLevel` (57 — the deepest level whose bucket span fits a `u64`), so any `u32` level is safe. The `GatherMode` decides what each bucket contributes, at the same level the renderer already picked: - **`Envelope`** (the default): one (min, max) pair per bucket, min drawn at the bucket's first sample and max at its last. The dense, extreme-reading geometry a time-series trace wants. - **`Nearest`**: one raw sample per bucket — the centre sample, the pick `pyramid().snap()` would make — drawn at its own x. Thinner geometry for traces whose *shape* matters more than their extremes. - **`Mean`**: one point per bucket, y = the bucket's finite samples' mean (the exact `bucket_sum()` the pyramid stores divided by its finite count), x at the bucket centre. A smoothed trace for noisy data. Level 0 is the raw samples for every mode. ## The SPSC contract One producer thread, one consumer thread, and the division is strict (`stream_series.hpp`): - **The producer may only call** `push(f32 y)`, `push(const f32* ys, u64 n)` and `produced()`. Those touch the ring buffer and nothing else: **no lock, no pyramid, no GL**. The acquisition thread can therefore never stall the renderer. - **The consumer calls `acquire()`**, which snapshots the produced count and brings the pyramid and bounds up to it, and then queries the published window. `size()`, `reduce()`, `sum()`, `sumsq()`, `gather()` and `pyramid()` all use the window published by the last `acquire()`. `acquire()` costs O(samples produced since the last acquire), not O(capacity): the pyramid is indexed in absolute sample space, so a sliding window only drops the leading buckets the ring overwrote and appends what is new; aggregates still in the window are never recomputed. The one exception is a producer that laps the consumer (more than `capacity()` samples between two acquires), where the missing samples cannot be represented and the pyramid restarts at the new base, re-reading the whole window. The ring overwrites the oldest samples — that is what keeps a live stream showing the newest data — so it is **not a frozen window**. Those slots are relaxed atomics, so this is not a data race and no value tears; but if the producer pushes `k` samples while a read is in flight, the first `k` positions of that read can hold one of those `k` newer samples rather than the one the snapshot named. Ingest with no reuse underneath a read at all needs the producer to stop before unread data (drop-on-full) or a claim protocol — a different contract from this one. `streaming()` returns `true` for `StreamSeries` and `false` for everything else. Only a streaming series has a **live edge** (the newest x it holds, which moves on its own), and it is that property `XFollow::Live` follows (see [interaction.md](interaction.md)). ## The min/max LOD pyramid `data::MinMaxPyramid` (`minmax_pyramid.hpp`) is the index that makes per-frame cost depend on the visible pixels rather than on the sample count. - Samples are grouped into buckets of `kBucket = 64` (**`kBucket`** is a fixed constant, so all series share one LOD geometry). Level `l` bucket `k` covers the absolute samples `[k * (kBucket << l), (k + 1) * (kBucket << l))`, and bucket boundaries are fixed for the pyramid's life — appending never re-buckets existing aggregates, and a window can slide over the same buckets. - Each level holds its complete buckets in a `std::deque` (O(1) `push_back` and `pop_front`) plus a `base`. `append()` folds a sample into the in-progress level-0 bucket and, when it completes, pushes it and walks up, stopping at the first level that gains no entry: an append costs **amortised O(1)**. `drop_before(u)` slides the window's leading edge, bounding the memory by the retained window rather than by everything ever appended. - An entry is 32 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold over and the finite-sample `count` the mean folds by), so level 0 costs `0.5n` bytes and the geometric tail brings the whole pyramid to about **`1n` bytes — roughly 25% of the `4n` bytes of raw samples** (`f32`). A min/max-only index would be `0.25n` (~6%); the extra words are the price of answering mean/RMS/integral from whole buckets instead of raw samples. Queries on the pyramid are **bucket-grained** and named `*_buckets` (`reduce_buckets`, `sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly for whole buckets, and partial edge buckets are excluded rather than approximated. Exact range queries live on `Series`, which owns the raw samples. `level_for(u64 n, f64 samples_per_pixel)` returns the pyramid level whose buckets hold about `samples_per_pixel` samples for a window of `n` samples (clamped to levels that exist, to levels with more than one bucket, and to levels that give the window at least two buckets). The renderer uses this: a trace's x range is mapped onto sample indices, and `Series::gather` is asked for that window *at `pyramid().level_for(n, samples_per_pixel)`* whenever a pixel covers `Style::dense_threshold_spp` samples or more (default 1.5). A 100-million-sample series therefore stages O(visible pixels) vertices, not O(samples), and `FrameStats` records which path each trace took. Below the threshold the raw samples are drawn instead; both sides go through the same instanced anti-aliased quad pipeline. ## Exact versus interpolated The library deliberately holds two conventions next to each other, and they do **not** agree: - **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is implemented once over `copy_raw()`: the bucket-aligned interior comes from the pyramid (O(log n) bucket aggregates) and at most two partial edge buckets are read raw and scanned, so the cost is O(`kBucket`) worst case and **independent of the series length**, and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these, never on the `*_buckets` forms, which would silently report wrong extremes for an unaligned interval. - **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`), and `crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what is the signal *at* this instant", which is generally between two samples — a different question from an interval statistic. The lookup is O(1) (one division and a floor) and allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue` (an em dash, `"—"`), and an x outside the series window yields no value rather than the nearest sample's. `Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**, `sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That is part of the result's contract, not an implementation detail. ## Measured frame times These are the demos' own frame summaries, taken from a `linux-release` build, **all of them llvmpipe (Mesa software rasteriser) under Xvfb** — no run touched a GPU. Treat them as upper bounds and as a record of the software path, not as hardware numbers. | demo | window | mean ms/frame | work | |---|---|---|---| | GLFW (`plot_glfw_demo`) | 1280×720 | 0.42 | one 1 MS/s `StreamSeries` | | ImGui (`plot_imgui_demo`) | 1280×720 | 0.37 | the same stream, embedded | | multiplot (`plot_multiplot_demo`) | 1280×720 | 0.66 | 3 rows, 6 traces, 500 kS/s each | | Qt6 (`plot_qt_demo`) | 820×588 at exit | 3.23 | one stream, `QOpenGLWidget` FBO | | GTK4 (`plot_gtk_demo`) | 810×564 at exit | 2.85 | one stream, `GtkGLArea` FBO | The multiplot run stages 18 651 segments for ~4.5 million visible samples, which is the pyramid doing its job: the frame is bounded by the pixels, not the samples. The Qt and GTK figures are dominated by their toolkit's texture-backed FBO under llvmpipe, not by the library.