diff --git a/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md b/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md index abfd853..092ba4e 100644 --- a/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md +++ b/docs/superpowers/plans/2026-09-27-plot-library-scaffold.md @@ -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 #include +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 d(-100.0f, 100.0f); std::vector 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 d(-3.0f, 7.0f); std::vector 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 +#include +#include +#include +#include +#include + PLOT_TEST(array_series_bounds_and_gather) { std::vector 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 d(-5.0f, 5.0f); + std::vector 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 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` 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& 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& 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 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 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` 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` 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 +#include +#include +#include + PLOT_TEST(nice_ticks_are_powers_of_ten_times_125) { std::vector 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 +#include +#include +#include +#include +#include + PLOT_TEST(measurement_over_interval_matches_direct_computation) { std::vector 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: diff --git a/docs/superpowers/specs/2026-09-27-plot-library-design.md b/docs/superpowers/specs/2026-09-27-plot-library-design.md index 0abbe23..7fbd50a 100644 --- a/docs/superpowers/specs/2026-09-27-plot-library-design.md +++ b/docs/superpowers/specs/2026-09-27-plot-library-design.md @@ -115,9 +115,17 @@ one stage macOS cannot be relied on for. ### 4.4 Interval reduction reuses the pyramid Cursor measurements (`Δx`, `Δy`, slope, RMS, mean, min/max, integral over a -dragged region) walk the same min/max chain and return in `O(log n)`; the series -is never scanned linearly. `pyramid().reduce(a, b)` is the single implementation -used both by measurement and by auto-scale. +dragged region) are answered from the same pyramid instead of by scanning the +series. `Series::reduce`/`sum`/`sumsq` cover the bucket-aligned interior with +pyramid aggregates and scan at most two partial edge buckets (≤ 2 × kBucket = 128 +samples), so a query's cost is independent of series length. `Series::reduce` is +the single implementation used by measurement, auto-scale and hit-testing. + +The split matters: a min/max pyramid stores *aggregates per bucket*, so it can +only answer exactly for whole buckets — a range with partial edge buckets would +otherwise silently include samples outside the range. Exactness therefore lives +on `Series` (which owns the raw samples), and `MinMaxPyramid` exposes only +bucket-exact queries, named `*_buckets` so they cannot be mistaken for exact ones. ### 4.5 Decimation approach — decision @@ -189,20 +197,41 @@ namespace data { public: void reserve(u64 n); void append(f32 v); void clear(); u64 size() const; - Range reduce(u64 begin, u64 end) const; // O(log n) u32 level_for(u64 n, f64 samples_per_pixel) const; std::span level(u32 l) const; u64 snap(f64 x, f64 x0, f64 dx) const; // nearest sample index + // Whole-bucket only: aggregates over the level-0 buckets fully contained in + // [begin, end). Partial edges are excluded, not approximated. Callers wanting + // an exact answer over an arbitrary range use Series::reduce/sum/sumsq. + Range reduce_buckets(u64 begin, u64 end) const; + f64 sum_buckets(u64 begin, u64 end) const; + f64 sumsq_buckets(u64 begin, u64 end) const; }; class Series { // abstract public: virtual ~Series() = default; virtual u64 size() const = 0; + virtual f64 x0() const = 0; // x of sample 0 + virtual f64 dx() const = 0; // uniform x spacing virtual Range x_bounds() const = 0; virtual Range y_bounds() const = 0; virtual const MinMaxPyramid& pyramid() const = 0; virtual void gather(u32 level, u64 begin, u64 end, std::vector& out_xy) const = 0; + + // Exact, O(kBucket) worst case regardless of series length: the bucket-aligned + // interior comes from the pyramid, the at-most-two partial edge buckets are + // read through copy_raw() and scanned. Implemented once, non-virtually, in + // src/data/series.cpp. Preconditions: begin < end, end <= size(). + Range reduce(u64 begin, u64 end) const; + f64 sum(u64 begin, u64 end) const; + f64 sumsq(u64 begin, u64 end) const; + + protected: + // Copies raw y samples for [begin, end) into `out`, which the caller sized to + // (end - begin). Handles ring wraparound. Only ever called with small edge + // spans by the queries above; not on a per-sample path. + virtual void copy_raw(u64 begin, u64 end, f32* out) const = 0; }; class ArraySeries final : public Series { // own storage, uniform dx