docs: exact range queries belong on Series, not on the bucket-grained pyramid
This commit is contained in:
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:**
|
**Interfaces:**
|
||||||
- Consumes: `plot::Range`, `plot::f32/f64/u64`, `plot::kInvalid`.
|
- 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**
|
- [ ] **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 <plot/data/minmax_pyramid.hpp>
|
||||||
#include <random>
|
#include <random>
|
||||||
|
|
||||||
|
using plot::data::kBucket;
|
||||||
using plot::data::MinMaxPyramid;
|
using plot::data::MinMaxPyramid;
|
||||||
using plot::Range;
|
using plot::Range;
|
||||||
|
|
||||||
@@ -504,28 +506,40 @@ PLOT_TEST(pyramid_bucket_extremes_preserved) {
|
|||||||
p.reserve(1000);
|
p.reserve(1000);
|
||||||
for (int i = 0; i < 1000; ++i) p.append(i == 617 ? -50.0f : (i == 42 ? 900.0f : 1.0f));
|
for (int i = 0; i < 1000; ++i) p.append(i == 617 ? -50.0f : (i == 42 ? 900.0f : 1.0f));
|
||||||
PLOT_CHECK(p.size() == 1000);
|
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.min, -50.0, 1e-6);
|
||||||
PLOT_CHECK_NEAR(r.max, 900.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::mt19937 rng(1234);
|
||||||
std::uniform_real_distribution<float> d(-100.0f, 100.0f);
|
std::uniform_real_distribution<float> d(-100.0f, 100.0f);
|
||||||
std::vector<float> v(5000);
|
std::vector<float> v(5000);
|
||||||
MinMaxPyramid p;
|
MinMaxPyramid p;
|
||||||
for (auto& x : v) { x = d(rng); p.append(x); }
|
for (auto& x : v) { x = d(rng); p.append(x); }
|
||||||
for (u64 a = 0; a < 5000; a += 137) {
|
for (u64 a = 0; a < 5000; a += kBucket * 3) {
|
||||||
for (u64 b = a + 1; b <= 5000; b += 511) {
|
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];
|
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]); }
|
for (u64 i = a; i < ahi; ++i) { lo = std::min(lo, v[i]); hi = std::max(hi, v[i]); }
|
||||||
const Range r = p.reduce(a, b);
|
const Range r = p.reduce_buckets(a, ahi);
|
||||||
PLOT_CHECK_NEAR(r.min, lo, 1e-4);
|
PLOT_CHECK_NEAR(r.min, lo, 1e-4);
|
||||||
PLOT_CHECK_NEAR(r.max, hi, 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) {
|
PLOT_TEST(pyramid_level_selection) {
|
||||||
MinMaxPyramid p;
|
MinMaxPyramid p;
|
||||||
p.reserve(1u << 20);
|
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_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::mt19937 rng(99);
|
||||||
std::uniform_real_distribution<float> d(-3.0f, 7.0f);
|
std::uniform_real_distribution<float> d(-3.0f, 7.0f);
|
||||||
std::vector<float> v(3000);
|
std::vector<float> v(3000);
|
||||||
MinMaxPyramid p;
|
MinMaxPyramid p;
|
||||||
for (auto& x : v) { x = d(rng); p.append(x); }
|
for (auto& x : v) { x = d(rng); p.append(x); }
|
||||||
for (u64 a = 0; a < 3000; a += 311) {
|
for (u64 a = 0; a < 3000; a += kBucket * 5) {
|
||||||
for (u64 b = a + 1; b <= 3000; b += 419) {
|
for (u64 b = a + kBucket; b <= 3000; b += kBucket * 9) {
|
||||||
double s = 0.0, sq = 0.0;
|
double s = 0.0, sq = 0.0;
|
||||||
for (u64 i = a; i < b; ++i) { s += v[i]; sq += double(v[i]) * double(v[i]); }
|
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.sum_buckets(a, b), s, 1e-3);
|
||||||
PLOT_CHECK_NEAR(p.sumsq_range(a, b), sq, 1e-2);
|
PLOT_CHECK_NEAR(p.sumsq_buckets(a, b), sq, 1e-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,6 +613,14 @@ PLOT_TEST(ring_clear_resets) {
|
|||||||
`tests/test_series.cpp`:
|
`tests/test_series.cpp`:
|
||||||
|
|
||||||
```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) {
|
PLOT_TEST(array_series_bounds_and_gather) {
|
||||||
std::vector<f32> y{1, 5, -2, 3};
|
std::vector<f32> y{1, 5, -2, 3};
|
||||||
plot::data::ArraySeries s(std::move(y), /*x0=*/10.0, /*dx=*/0.5);
|
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(s.size() <= 256);
|
||||||
PLOT_CHECK_NEAR(s.y_bounds().max, 999.0, 1e-4);
|
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**
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
@@ -663,9 +720,11 @@ class MinMaxPyramid {
|
|||||||
u64 size() const noexcept { return size_; }
|
u64 size() const noexcept { return size_; }
|
||||||
u32 level_count() const noexcept { return u32(levels_.size()); }
|
u32 level_count() const noexcept { return u32(levels_.size()); }
|
||||||
std::span<const MinMax> level(u32 l) const;
|
std::span<const MinMax> level(u32 l) const;
|
||||||
Range reduce(u64 begin, u64 end) const; // half-open [begin, end)
|
// Whole-bucket only: [begin, end) is intersected with whole level-0 buckets and
|
||||||
f64 sum_range(u64 begin, u64 end) const; // exact sum over [begin, end)
|
// partial edges are excluded, never approximated. An empty result has span()==0.
|
||||||
f64 sumsq_range(u64 begin, u64 end) const; // exact sum of squares
|
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;
|
u32 level_for(u64 n, f64 samples_per_pixel) const;
|
||||||
u64 snap(f64 x, f64 x0, f64 dx) const;
|
u64 snap(f64 x, f64 x0, f64 dx) const;
|
||||||
|
|
||||||
@@ -678,18 +737,19 @@ class MinMaxPyramid {
|
|||||||
```
|
```
|
||||||
|
|
||||||
Implementation rules for `src/data/minmax_pyramid.cpp`:
|
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).
|
- `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(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.
|
- `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`.
|
- `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]`.
|
- `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/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.
|
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`:
|
`tests/test_ticks.cpp`:
|
||||||
|
|
||||||
```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) {
|
PLOT_TEST(nice_ticks_are_powers_of_ten_times_125) {
|
||||||
std::vector<plot::Tick> t;
|
std::vector<plot::Tick> t;
|
||||||
plot::generate_ticks({0.0, 1.0}, /*px=*/500.0f, /*min_px=*/60.0f, 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`:
|
`tests/test_measure.cpp`:
|
||||||
|
|
||||||
```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) {
|
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)));
|
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);
|
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 Measure::evaluate(const data::Series& s, u64 begin, u64 end) {
|
||||||
Measurement r{};
|
Measurement r{};
|
||||||
if (end <= begin || end > s.size()) return r;
|
if (end <= begin || end > s.size()) return r;
|
||||||
// Sums (mean, rms, integral): O(1) interior from level-0 prefix sums plus a
|
// Every quantity comes from Series, never from the pyramid's *_buckets forms:
|
||||||
// scan of at most two partial edge buckets (<= 2*kBucket samples).
|
// const Range r = s.reduce(begin, end);
|
||||||
// Extrema: pyramid().reduce(begin, end), O(log n), never scans.
|
// 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;
|
// dx = (end - 1 - begin) * dx_of_series;
|
||||||
// slope = (y[end-1] - y[begin]) / dx (0 when dx == 0);
|
// slope = (y[end-1] - y[begin]) / dx (0 when dx == 0);
|
||||||
// dy = y[end-1] - y[begin];
|
// dy = y[end-1] - y[begin];
|
||||||
@@ -973,7 +1049,7 @@ Measurement Measure::evaluate(const data::Series& s, u64 begin, u64 end) {
|
|||||||
return r;
|
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:
|
`include/plot/plot/plot.hpp` — the public façade, exactly the API in spec §6 plus:
|
||||||
|
|
||||||
|
|||||||
@@ -115,9 +115,17 @@ one stage macOS cannot be relied on for.
|
|||||||
### 4.4 Interval reduction reuses the pyramid
|
### 4.4 Interval reduction reuses the pyramid
|
||||||
|
|
||||||
Cursor measurements (`Δx`, `Δy`, slope, RMS, mean, min/max, integral over a
|
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
|
dragged region) are answered from the same pyramid instead of by scanning the
|
||||||
is never scanned linearly. `pyramid().reduce(a, b)` is the single implementation
|
series. `Series::reduce`/`sum`/`sumsq` cover the bucket-aligned interior with
|
||||||
used both by measurement and by auto-scale.
|
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
|
### 4.5 Decimation approach — decision
|
||||||
|
|
||||||
@@ -189,20 +197,41 @@ namespace data {
|
|||||||
public:
|
public:
|
||||||
void reserve(u64 n); void append(f32 v); void clear();
|
void reserve(u64 n); void append(f32 v); void clear();
|
||||||
u64 size() const;
|
u64 size() const;
|
||||||
Range reduce(u64 begin, u64 end) const; // O(log n)
|
|
||||||
u32 level_for(u64 n, f64 samples_per_pixel) const;
|
u32 level_for(u64 n, f64 samples_per_pixel) const;
|
||||||
std::span<const MinMax> level(u32 l) const;
|
std::span<const MinMax> level(u32 l) const;
|
||||||
u64 snap(f64 x, f64 x0, f64 dx) const; // nearest sample index
|
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
|
class Series { // abstract
|
||||||
public:
|
public:
|
||||||
virtual ~Series() = default;
|
virtual ~Series() = default;
|
||||||
virtual u64 size() const = 0;
|
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 x_bounds() const = 0;
|
||||||
virtual Range y_bounds() const = 0;
|
virtual Range y_bounds() const = 0;
|
||||||
virtual const MinMaxPyramid& pyramid() const = 0;
|
virtual const MinMaxPyramid& pyramid() const = 0;
|
||||||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
|
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& 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
|
class ArraySeries final : public Series { // own storage, uniform dx
|
||||||
|
|||||||
Reference in new issue
Block a user