# 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. ## Dense traces are reduced onto the pixel grid A gathered window denser than the axis is **not** staged as a polyline. For each integer column of `layout.axes`, `Renderer::draw_traces` emits one exactly vertical segment from the lowest to the highest screen y any gathered point put in that column, flushed in framebuffer pixels with `u_snap` on. `FrameStats::column_traces` counts the traces that took this path. It exists because a polyline over such a window **moirés**, and three things compound to make it. `MinMaxPyramid::level_for()` picks the level whose buckets hold about `samples_per_pixel` samples, so a bucket spans **one to two pixel columns**; `GatherMode::Envelope` emits two points per bucket, so each bucket contributes a near-vertical min→max hairline *and* a near-vertical connector to the next — about 3.6 near-vertical hairlines per column; and each of those lands at a **fractional** x, so with ordinary src-alpha compositing a column's brightness is a non-linear function of the sub-pixel phases overlapping it. The phase pattern repeats at the beat between the bucket pitch and the pixel pitch, which is a visible banding, and panning sweeps the beat so it crawls. Reducing to one segment per column leaves the trace **one** sub-pixel phase, so there is nothing left to beat, and the snap then puts that one phase on the grid. Four properties of the reduction are load-bearing: - **It is gated on the gathered *density*, not on the level.** It runs when the window is `GatherMode::Envelope` and carries at least two gathered points per column of the axes — the raw path at 1.5–64 samples per pixel has the same beat and the same cure. The density term is also what keeps it away from a *sparse* window, whose trace is drawn by the two segments that enter and leave the frame (`a_sparse_window_between_samples_still_inks_both_margins`); `Nearest` and `Mean` are excluded because they are one-point-per-bucket modes a band defeats. - **A hole stays a hole.** A column no gathered point landed in emits nothing. Bridging it would draw a vertical line over x the series says nothing about. - **Disjoint columns are joined.** Two neighbouring columns' extremes need not overlap — a fast edge rises through a whole column — and two disjoint vertical segments read as a broken trace, which is what the polyline's connector prevented. The later column is extended to meet the earlier one's nearest bound, so the join is made inside the column that moved. - **A span shorter than the stroke is drawn *as* the stroke.** The line fragment shader ramps on the distance to the segment's axis and does not cap its ends, so a vertical segment shorter than its own width rasterises a sliver of itself, and a flat trace's column, where the two bounds are equal, nothing at all. This is the **one carve-out** in `u_snap`'s rule that chrome may be snapped and data may not (see `LineProgram::set_snap()`): a column *is* a pixel column and its x is already a screen coordinate, so there is no data position to move it off, and the y bounds the segment reports are untouched by the snap. `a_dense_trace_is_drawn_as_whole_pixel_columns` is the guard, and what it measures is the **number of distinct coverage levels** in the ink: 4 with the reduction against 126 with the polyline, over one sine period across 60 000 samples. A count of *partially covered pixels* is **not** the reading and was tried first — a 1.5 px stroke with a 1 px anti-aliasing fringe always spills into its neighbour column, so the two renderings measure 577 and 652, which separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054. ## One sample or a statistic of many Every number the library reports is one the samples actually hold. What differs between these two is *how many* samples answer the question: - **`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 a single sample — the nearest one — by default.** `crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`), and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the earlier, otherwise the later. It answers "which datum is under the pointer", not "what is the signal *at* this instant" — a linearly interpolated y is a number the series does not contain, and on a source whose samples are already averaged or decimated at ingest it is a claim about a signal nobody measured. That is why it is the default; a host whose x is a quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets `crosshair_interpolate(y_begin, y_end, t)` instead, at the crosshair's own x. The lookup is O(1) and allocation-free either way, and it reads level 0, so the value is a stored sample (or a blend of two) and never an LOD bucket's aggregate. `Plot::crosshair_point(trace, x, out_x, out_y)` exposes **both** coordinates to a host and `crosshair_value()` the y alone; `CrosshairRow::x`/`y` and its `marker` are placed from that same pair, so in `NearestSample` the mark snaps onto the quoted sample and the dot and the number cannot disagree. 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 clamping to the first or last sample. `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.