docs: exact range queries belong on Series, not on the bucket-grained pyramid

This commit is contained in:
Martino Ferrari committed 2026-09-27 21:20:22 +02:00
1 parent 6866457155
commit c93c98002b
2 files changed
+133 -28

No files matched your search

@@ -485,7 +485,8 @@ git add -A && git commit -m "feat(gl): dependency-free GL loader, RAII handles,
**Interfaces:**
- Consumes: `plot::Range`, `plot::f32/f64/u64`, `plot::kInvalid`.
- Produces: `plot::data::RingBuffer`, `plot::data::MinMaxPyramid`, `plot::data::Series`, `plot::data::ArraySeries`, `plot::data::StreamSeries` exactly as declared in spec §6, plus `plot::data::MinMax{min,max}`, `plot::data::kBucket == 64`, and `MinMaxPyramid::sum_range`/`sumsq_range` (exact interval sums, O(kBucket) worst case — consumed by Task 5 measurements and by auto-scale).
- Produces: `plot::data::RingBuffer`, `plot::data::MinMaxPyramid`, `plot::data::Series`, `plot::data::ArraySeries`, `plot::data::StreamSeries` exactly as declared in spec §6, plus `plot::data::MinMax{min,max}` and `plot::data::kBucket == 64`.
**Exactness split (do not get this wrong):** a min/max pyramid stores one aggregate per bucket, so it can only answer exactly for *whole* buckets. Exact range queries therefore live on `Series` — `reduce`, `sum`, `sumsq`, non-virtual, implemented once in `src/data/series.cpp` over the protected `copy_raw` hook: the bucket-aligned interior comes from the pyramid, and at most two partial edge buckets are copied through `copy_raw` and scanned. `MinMaxPyramid` exposes only the bucket-exact `reduce_buckets`/`sum_buckets`/`sumsq_buckets` that back them. Task 5 measurements and auto-scale consume `Series::reduce`/`sum`/`sumsq` and never the `*_buckets` forms directly.
- [ ] **Step 1: Write the failing tests**
@@ -496,6 +497,7 @@ git add -A && git commit -m "feat(gl): dependency-free GL loader, RAII handles,
#include <plot/data/minmax_pyramid.hpp>
#include <random>
using plot::data::kBucket;
using plot::data::MinMaxPyramid;
using plot::Range;
@@ -504,28 +506,40 @@ PLOT_TEST(pyramid_bucket_extremes_preserved) {
p.reserve(1000);
for (int i = 0; i < 1000; ++i) p.append(i == 617 ? -50.0f : (i == 42 ? 900.0f : 1.0f));
PLOT_CHECK(p.size() == 1000);
const Range r = p.reduce(0, 1000);
const Range r = p.reduce_buckets(0, 960); // exactly 15 whole buckets
PLOT_CHECK_NEAR(r.min, -50.0, 1e-6);
PLOT_CHECK_NEAR(r.max, 900.0, 1e-6);
}
PLOT_TEST(pyramid_reduce_matches_bruteforce) {
PLOT_TEST(pyramid_reduce_buckets_matches_bruteforce_on_aligned_ranges) {
std::mt19937 rng(1234);
std::uniform_real_distribution<float> d(-100.0f, 100.0f);
std::vector<float> v(5000);
MinMaxPyramid p;
for (auto& x : v) { x = d(rng); p.append(x); }
for (u64 a = 0; a < 5000; a += 137) {
for (u64 b = a + 1; b <= 5000; b += 511) {
for (u64 a = 0; a < 5000; a += kBucket * 3) {
for (u64 b = a + kBucket; b <= 5000; b += kBucket * 7) {
const u64 ahi = (b / kBucket) * kBucket; // whole buckets only
if (ahi <= a) continue;
float lo = v[a], hi = v[a];
for (u64 i = a; i < b; ++i) { lo = std::min(lo, v[i]); hi = std::max(hi, v[i]); }
const Range r = p.reduce(a, b);
for (u64 i = a; i < ahi; ++i) { lo = std::min(lo, v[i]); hi = std::max(hi, v[i]); }
const Range r = p.reduce_buckets(a, ahi);
PLOT_CHECK_NEAR(r.min, lo, 1e-4);
PLOT_CHECK_NEAR(r.max, hi, 1e-4);
}
}
}
PLOT_TEST(pyramid_reduce_buckets_excludes_partial_edges) {
// Bucket 1 covers samples [64,128). reduce_buckets answers in whole buckets
// only, so the spike must show up in its own bucket and nowhere else.
MinMaxPyramid p;
for (int i = 0; i < 128; ++i) p.append(i == 70 ? 999.0f : 1.0f);
PLOT_CHECK_NEAR(p.reduce_buckets(64, 128).max, 999.0, 1e-6);
PLOT_CHECK_NEAR(p.reduce_buckets(0, 64).max, 1.0, 1e-6);
PLOT_CHECK(p.reduce_buckets(64, 64).span() == 0.0); // empty -> degenerate
}
PLOT_TEST(pyramid_level_selection) {
MinMaxPyramid p;
p.reserve(1u << 20);
@@ -547,18 +561,18 @@ PLOT_TEST(pyramid_snap_returns_nearest_sample) {
PLOT_CHECK(p.snap(1e9, 0.0, 1.0) == 99); // clamped
}
PLOT_TEST(pyramid_sums_match_bruteforce) {
PLOT_TEST(pyramid_sums_match_bruteforce_on_aligned_ranges) {
std::mt19937 rng(99);
std::uniform_real_distribution<float> d(-3.0f, 7.0f);
std::vector<float> v(3000);
MinMaxPyramid p;
for (auto& x : v) { x = d(rng); p.append(x); }
for (u64 a = 0; a < 3000; a += 311) {
for (u64 b = a + 1; b <= 3000; b += 419) {
for (u64 a = 0; a < 3000; a += kBucket * 5) {
for (u64 b = a + kBucket; b <= 3000; b += kBucket * 9) {
double s = 0.0, sq = 0.0;
for (u64 i = a; i < b; ++i) { s += v[i]; sq += double(v[i]) * double(v[i]); }
PLOT_CHECK_NEAR(p.sum_range(a, b), s, 1e-3);
PLOT_CHECK_NEAR(p.sumsq_range(a, b), sq, 1e-2);
PLOT_CHECK_NEAR(p.sum_buckets(a, b), s, 1e-3);
PLOT_CHECK_NEAR(p.sumsq_buckets(a, b), sq, 1e-2);
}
}
}
@@ -599,6 +613,14 @@ PLOT_TEST(ring_clear_resets) {
`tests/test_series.cpp`:
```cpp
#include "harness.hpp"
#include <plot/data/array_series.hpp>
#include <plot/data/stream_series.hpp>
#include <algorithm>
#include <cmath>
#include <random>
#include <vector>
PLOT_TEST(array_series_bounds_and_gather) {
std::vector<f32> y{1, 5, -2, 3};
plot::data::ArraySeries s(std::move(y), /*x0=*/10.0, /*dx=*/0.5);
@@ -634,6 +656,41 @@ PLOT_TEST(stream_series_drops_oldest_when_full) {
PLOT_CHECK(s.size() <= 256);
PLOT_CHECK_NEAR(s.y_bounds().max, 999.0, 1e-4);
}
PLOT_TEST(array_series_exact_range_queries_match_bruteforce) {
// The strong test for the exactness split: arbitrary, deliberately
// unaligned ranges must match a brute-force scan of the raw samples.
std::mt19937 rng(7);
std::uniform_real_distribution<float> d(-5.0f, 5.0f);
std::vector<f32> y(4000);
for (auto& v : y) v = d(rng);
plot::data::ArraySeries s(y, 0.0, 1.0);
for (u64 a = 0; a < 4000; a += 173) {
for (u64 b = a + 1; b <= 4000; b += 331) {
float lo = y[a], hi = y[a];
double sum = 0.0, sq = 0.0;
for (u64 i = a; i < b; ++i) {
lo = std::min(lo, y[i]); hi = std::max(hi, y[i]);
sum += y[i]; sq += double(y[i]) * double(y[i]);
}
const plot::Range r = s.reduce(a, b);
PLOT_CHECK_NEAR(r.min, lo, 1e-4);
PLOT_CHECK_NEAR(r.max, hi, 1e-4);
PLOT_CHECK_NEAR(s.sum(a, b), sum, 1e-2);
PLOT_CHECK_NEAR(s.sumsq(a, b), sq, 1e-1);
}
}
}
PLOT_TEST(stream_series_exact_queries_survive_ring_wrap) {
plot::data::StreamSeries s(1u << 10, 0.0, 1.0);
for (int i = 0; i < 3000; ++i) s.push(f32(i % 97)); // wraps the ring twice
s.acquire();
const u64 n = s.size();
const plot::Range r = s.reduce(0, n); // retained window, wrapped
PLOT_CHECK_NEAR(r.max, 96.0, 1e-4);
PLOT_CHECK_NEAR(r.min, 0.0, 1e-4);
}
```
- [ ] **Step 2: Run tests to verify they fail**
@@ -663,9 +720,11 @@ class MinMaxPyramid {
u64 size() const noexcept { return size_; }
u32 level_count() const noexcept { return u32(levels_.size()); }
std::span<const MinMax> level(u32 l) const;
Range reduce(u64 begin, u64 end) const; // half-open [begin, end)
f64 sum_range(u64 begin, u64 end) const; // exact sum over [begin, end)
f64 sumsq_range(u64 begin, u64 end) const; // exact sum of squares
// Whole-bucket only: [begin, end) is intersected with whole level-0 buckets and
// partial edges are excluded, never approximated. An empty result has span()==0.
Range reduce_buckets(u64 begin, u64 end) const;
f64 sum_buckets(u64 begin, u64 end) const;
f64 sumsq_buckets(u64 begin, u64 end) const;
u32 level_for(u64 n, f64 samples_per_pixel) const;
u64 snap(f64 x, f64 x0, f64 dx) const;
@@ -678,18 +737,19 @@ class MinMaxPyramid {
```
Implementation rules for `src/data/minmax_pyramid.cpp`:
- `append(v)`: write/merge into the last bucket of `levels_[0]`, then for each level `l`, if level `l` gained a complete pair, merge the last two entries of level `l` into a new entry of level `l+1`; otherwise stop early. Amortised O(1).
- `reduce(begin, end)`: `if (end <= begin) return {0,0};` walk the range: handle the partial head bucket and partial tail bucket by consulting `levels_[0]`, and cover the aligned middle by descending to the coarsest level whose buckets are fully inside the range. Never scan raw samples — the pyramid stores no raw samples.
- `append(v)`: write/merge into the last bucket of `levels_[0]`, then for each level `l`, if level `l` gained a complete pair, merge the last two entries of level `l` into a new entry of level `l+1`; otherwise stop early. Amortised O(1). The same walk maintains `sums_`/`sumsqs_` (inclusive prefix sums per level).
- `reduce_buckets(begin, end)`: `if (end <= begin) return {0,0};` intersect `[begin, end)` with whole level-0 buckets, then cover the result by descending to the coarsest level whose buckets lie entirely inside it, finishing the remainder at level 0. It must NEVER scan raw samples — it stores none — and a partial edge bucket is excluded, not approximated. `sum_buckets`/`sumsq_buckets` use the same decomposition over `sums_`/`sumsqs_`.
- `Series::reduce`/`sum`/`sumsq` in `src/data/series.cpp` are the exact entry points every other module uses: they take the bucket-aligned interior from `reduce_buckets`/`sum_buckets`/`sumsq_buckets` and scan the at-most-two partial edge buckets obtained via `copy_raw` (a stack buffer of at most 2*kBucket floats, so no allocation on the query path).
- `level_for(n, spp)`: `if (spp <= f64(kBucket)) return 0;` else `u32 l = u32(std::floor(std::log2(spp / f64(kBucket))));` clamp to `level_count() - 1`, and additionally clamp so that `level(l).size() >= 2`.
- `snap(x, x0, dx)`: `if (dx <= 0.0 || size_ == 0) return 0;` compute `i = llround((x - x0) / dx)` and clamp to `[0, size_ - 1]`.
`include/plot/data/ring_buffer.hpp`: `RingBuffer<T>` with power-of-two capacity, atomic producer index, `push(T)` overwriting the oldest entry when full, `size()`, `operator[](u64)` indexing oldest→newest, `clear()`.
`include/plot/data/series.hpp`: the abstract `Series` from spec §6, `virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;` documented as "appends interleaved (x, y) pairs in data coordinates for bucket-aligned level `level`; level 0 emits raw samples".
`include/plot/data/series.hpp`: the abstract `Series` from spec §6. `virtual f64 x0() const` and `virtual f64 dx() const` expose the uniform x grid (measurement and `snap` need them). `virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;` is documented as "appends interleaved (x, y) pairs in data coordinates for bucket-aligned level `level`; level 0 emits raw samples". `reduce`/`sum`/`sumsq` are non-virtual, implemented once in `src/data/series.cpp` over the protected pure virtual `copy_raw(u64 begin, u64 end, f32* out) const`, which copies `end - begin` raw y values and handles any wraparound.
`ArraySeries`: holds `std::vector<f32> y_`, `f64 x0_`, `f64 dx_`, a `MinMaxPyramid`, and cached `Range y_bounds_`/`Range x_bounds_`. Constructor and `reset()` fill the pyramid and bounds; `commit()` rebuilds them (for when the caller mutated the vector out of band — document that the vector is owned and mutated only via `reset`).
`ArraySeries`: holds `std::vector<f32> y_`, `f64 x0_`, `f64 dx_`, a `MinMaxPyramid`, and cached `Range y_bounds_`/`Range x_bounds_`. Constructor and `reset()` fill the pyramid and bounds; `commit()` rebuilds them (for when the caller mutated the vector out of band — document that the vector is owned and mutated only via `reset`). `copy_raw` is a bounds-checked `std::copy` over `y_`.
`StreamSeries`: `RingBuffer<f32>` of rounded-up capacity, atomic `produced_`, `f64 x0_`, `f64 dx_`; `push` is the only producer entry point and MUST NOT touch the pyramid; `acquire()` reads `produced_` once, grows the pyramid by the newly produced samples, and updates bounds. `x_bounds()` derives from the oldest retained index and `dx_`, so a wrap shifts the window forward.
`StreamSeries`: `RingBuffer<f32>` of rounded-up capacity, atomic `produced_`, `f64 x0_`, `f64 dx_`; `push` is the only producer entry point and MUST NOT touch the pyramid; `acquire()` reads `produced_` once, grows the pyramid by the newly produced samples, and updates bounds. `x_bounds()` derives from the oldest retained index and `dx_`, so a wrap shifts the window forward. `copy_raw` must split the copy at the ring's wrap point; `reduce`/`sum`/`sumsq` are documented as operating on the *retained* window, so index 0 is the oldest sample still held.
Add `test_ring_buffer.cpp`, `test_minmax_pyramid.cpp` and `test_series.cpp` to the existing `plot_tests` target's source list in `tests/CMakeLists.txt`. Do not add new `add_test` entries — one `add_test(NAME plot_tests ...)` runs every registered case.
@@ -862,6 +922,12 @@ git add -A && git commit -m "feat(render): style, GLSL line/text pipelines, stag
`tests/test_ticks.cpp`:
```cpp
#include "harness.hpp"
#include <plot/plot/ticks.hpp>
#include <cmath>
#include <string>
#include <vector>
PLOT_TEST(nice_ticks_are_powers_of_ten_times_125) {
std::vector<plot::Tick> t;
plot::generate_ticks({0.0, 1.0}, /*px=*/500.0f, /*min_px=*/60.0f, t);
@@ -896,6 +962,14 @@ PLOT_TEST(degenerate_ranges_do_not_hang) {
`tests/test_measure.cpp`:
```cpp
#include "harness.hpp"
#include <plot/plot/measure.hpp>
#include <plot/data/array_series.hpp>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <vector>
PLOT_TEST(measurement_over_interval_matches_direct_computation) {
std::vector<f32> y; for (int i = 0; i < 1000; ++i) y.push_back(f32(std::sin(i * 0.01)));
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
@@ -963,9 +1037,11 @@ Labels are assigned by `format_si` unless the axis is a time axis, in which case
Measurement Measure::evaluate(const data::Series& s, u64 begin, u64 end) {
Measurement r{};
if (end <= begin || end > s.size()) return r;
// Sums (mean, rms, integral): O(1) interior from level-0 prefix sums plus a
// scan of at most two partial edge buckets (<= 2*kBucket samples).
// Extrema: pyramid().reduce(begin, end), O(log n), never scans.
// Every quantity comes from Series, never from the pyramid's *_buckets forms:
// const Range r = s.reduce(begin, end);
// const f64 total = s.sum(begin, end), sq = s.sumsq(begin, end);
// Each is O(kBucket) worst case: bucket-aligned interior from the pyramid,
// plus at most two partial edge buckets read through copy_raw().
// dx = (end - 1 - begin) * dx_of_series;
// slope = (y[end-1] - y[begin]) / dx (0 when dx == 0);
// dy = y[end-1] - y[begin];
@@ -973,7 +1049,7 @@ Measurement Measure::evaluate(const data::Series& s, u64 begin, u64 end) {
return r;
}
```
`reduce()` supplies `ymin`/`ymax` in O(log n) and never scans the series. mean/rms/integral use `MinMaxPyramid::sum_range`/`sumsq_range` (Task 3): O(1) over bucket-aligned interiors from the level-0 prefix sums, plus a direct scan of at most two partial edge buckets (<= 2 x kBucket = 128 samples). A measurement therefore never scales with series length, which is exactly what `measurement_uses_pyramid_not_scan` gates. `integral = sum_range(begin,end) * dx`, `mean = sum_range/n`, `rms = sqrt(sumsq_range/n)`, where `n = end - begin`.
Take every quantity from `Series` (Task 3), never from the pyramid directly: `s.reduce(begin, end)` supplies exact `ymin`/`ymax`, and `s.sum(begin, end)` / `s.sumsq(begin, end)` supply the sums. Each is O(kBucket) worst case — the bucket-aligned interior comes from the pyramid, and at most two partial edge buckets (<= 2 x kBucket = 128 samples) are scanned through `copy_raw` — so a measurement never scales with series length, which is exactly what `measurement_uses_pyramid_not_scan` gates. `integral = s.sum(begin, end) * s.dx()`, `mean = sum/n`, `rms = sqrt(sumsq/n)` with `n = end - begin`, and `dx = (end - 1 - begin) * s.dx()` (0 when `end - begin < 2`). Calling the pyramid's `*_buckets` forms here would silently drop the edge samples and return wrong extrema — that is the specific mistake this paragraph exists to prevent.
`include/plot/plot/plot.hpp` — the public façade, exactly the API in spec §6 plus: