fix(data): draw the partial edge buckets at LOD levels, correct the pyramid's memory and precondition docs

Findings from the review of the sliding-window change:

- A level >= 1 gather emitted only complete, wholly-retained buckets, so the
  geometry stopped at the last bucket boundary inside the range: the newest
  samples of a live window -- up to `span - 1` of them, about a pixel at the
  level level_for() picks -- were not drawn, on the right edge, which is the
  part of a scrolling trace that gets watched, and the oldest retained samples
  fell outside the geometry too (the level-1 gather of a sliding window started
  51 samples in). The two partial edge buckets are now gathered as raw samples
  through copy_raw() and bracketed around the envelope -- the same exactness
  split reduce/sum/sumsq already use -- so the emitted geometry spans the
  requested range exactly. The pyramid still stores no partial bucket, so append
  stays amortised O(1); at 1 MS/s the demo stages 511 extra sub-pixel segments
  and still draws at 1.05 ms/frame, and the screenshot's trace now reaches one
  column further at each end.
- `drop_before`'s doc claimed a no-op "for an empty pyramid": false, and
  backwards -- the restart branch is exactly what an empty pyramid hits, which is
  what StreamSeries::acquire() depends on when the ring saturated before the
  first acquire. The precondition is `absolute_sample <= window_base()`, and the
  restart is documented as what it is.
- The pyramid's memory figure was 3x optimistic: an entry is 24 bytes (MinMax
  plus the exact sum/sumsq doubles), so level 0 costs 0.375n bytes and the whole
  pyramid ~0.75n -- about 19% of 4-byte raw samples, not the 6% a min/max-only
  index would cost. Corrected in the header, spec 4.2 and 4.5, with the trade
  stated (the doubles buy O(log n) sum/mean/RMS/integral from whole buckets),
  and the plan's Task 3 sketch no longer describes per-level prefix sums, a
  level() span or an unaligned-end widening that does not exist.

Tests: `gather_at_a_lod_level_reaches_both_ends_of_the_window` pins that the
first and last emitted points are the oldest and newest retained samples -- with
spike values no bucket envelope can produce -- at level 1, at the level a dense
view picks, and for a static series whose end is unaligned. It fails against the
previous commit (first point at the first whole bucket), and so does the sliding
gather mapping check, which now accounts for the raw edge pairs.
This commit is contained in:
Martino Ferrari committed 2026-09-28 00:19:59 +02:00
1 parent c8a97e4a1b
commit fb2c2f5e4d
6 files changed
+199 -56

No files matched your search

+20 -10
View File
@@ -33,10 +33,15 @@ inline constexpr u32 kBucket = 64;
///
/// `drop_before(u)` slides the window's leading edge to absolute sample `u`: each
/// level discards the buckets whose span starts before `u`, so the memory is
/// bounded by the retained window (about n/4 bytes, ~6% of 4-byte raw samples)
/// and not by the number of samples ever appended. Every stored entry is the
/// complete aggregate of its own span and every stored span lies inside the
/// retained window, so no aggregate is ever recomputed or invalidated.
/// bounded by the retained window and not by the number of samples ever appended.
/// A bucket 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, ~19% of the 4n bytes of raw
/// samples. (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.) Every stored entry is the complete aggregate of its own span and
/// every stored span lies inside the retained window, so no aggregate is ever
/// recomputed or invalidated.
///
/// Queries are in *window* coordinates: index 0 is the oldest retained sample,
/// `size()` is the retained sample count, and `window_base()` is the absolute
@@ -59,12 +64,15 @@ class MinMaxPyramid {
void append(f32 v);
/// Slides the retained window's leading edge to absolute sample index
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
/// Monotonic for arguments ahead of the base; a no-op for arguments at or
/// behind it, and for an empty pyramid.
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
/// pyramid is not exempt, because a caller whose window already starts past
/// everything appended so far has to be able to say so (that is exactly what a
/// ring that saturated before the first acquire() passes here).
///
/// An argument past `window_end()` (a producer that outran the consumer by more
/// than the ring held) drops everything and restarts the window there: the
/// samples in between were never appended, so they are not representable.
/// An argument past `window_end()` -- a producer that outran the consumer by
/// more than the ring held -- drops every aggregate and restarts the window at
/// `absolute_sample`: the samples in between were never appended, so they are
/// not representable, and the window the caller reads is the new one.
void drop_before(u64 absolute_sample);
void clear();
@@ -108,7 +116,9 @@ class MinMaxPyramid {
u64 snap(f64 x, f64 x0, f64 dx) const;
private:
/// One bucket: its extremes plus the raw-sum aggregates Series folds over.
/// One bucket: its extremes plus its own exact sum and sum of squares. The two
/// doubles take a bucket from 8 bytes to 24, which is what lets mean/RMS/
/// integral come from whole buckets instead of raw samples.
struct Entry {
MinMax mm;
f64 sum = 0.0;