# Data and performance ## The sample stores All three series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which describes a **uniform-grid** series: sample `i` lives at `x = x0() + i * dx()`. ```cpp class Series { public: virtual u64 size() const; virtual f64 x0() const; // x of sample 0 (the oldest retained sample) virtual f64 dx() const; virtual Range x_bounds() const; virtual Range y_bounds() const; virtual const MinMaxPyramid& pyramid() const; virtual bool streaming() const noexcept; // default false virtual void gather(u32 level, u64 begin, u64 end, std::vector& out_xy) 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) or `commit()`, which rebuilds the pyramid and cached bounds from the current `y`. **`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`. ## 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 24 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold over), so level 0 costs `0.375n` bytes and the geometric tail brings the whole pyramid to about **`0.75n` bytes — roughly 19% of the `4n` bytes of raw samples** (`f32`). A min/max-only index would be `0.25n` (~6%); the two doubles 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.