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:
1 parent
c8a97e4a1b
commit
fb2c2f5e4d
6 files changed
+199
-56
No files matched your search
+87
-12
@@ -245,25 +245,41 @@ PLOT_TEST(stream_series_sliding_window_queries_match_bruteforce) {
|
||||
PLOT_CHECK_NEAR(m.max, want[1], 1e-6);
|
||||
}
|
||||
|
||||
// level-1 gather: one envelope pair per retained bucket the range overlaps, x
|
||||
// at the bucket's own first and last sample. This is the mapping the renderer
|
||||
// draws from.
|
||||
// level-1 gather: the envelope pairs of the retained buckets, bracketed by the
|
||||
// two partial edge buckets as raw samples, so the geometry spans the range
|
||||
// exactly. This is the mapping the renderer draws from.
|
||||
std::vector<f32> out;
|
||||
s.gather(1, 0, n, out);
|
||||
const u64 first = std::max(base / span, lbase);
|
||||
const u64 last = std::min((base + n - 1) / span + 1, lbase + lsize);
|
||||
PLOT_CHECK(out.size() == 4 * (last - first));
|
||||
for (u64 bi = first; bi < last; ++bi) {
|
||||
const u64 lead = first * span - base; // raw pairs before the first whole bucket
|
||||
const u64 trail = last * span - base; // first window index after the last one
|
||||
const u64 tail = trail < n ? n - trail : 0;
|
||||
const u64 env = last - first;
|
||||
PLOT_CHECK(out.size() == 2 * (lead + 2 * env + tail)); // each bucket is two pairs
|
||||
for (u64 i = 0; i < lead; ++i) { // the partial leading bucket, raw
|
||||
PLOT_CHECK_NEAR(out[2 * i], f32(f64(base + i) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[2 * i + 1], value(base + i), 1e-6);
|
||||
}
|
||||
for (u64 j = 0; j < env; ++j) {
|
||||
// Each whole bucket contributes two pairs: (first sample, min) and (last
|
||||
// sample, max). x is in data coordinates -- x0() is the retained window's
|
||||
// first sample -- so window index i plots at (base + i) * dx.
|
||||
const u64 bi = first + j;
|
||||
const std::array<f64, 4> want = brute(bi * span - base, (bi + 1) * span - base);
|
||||
const plot::usize k = static_cast<plot::usize>(bi - first);
|
||||
const u64 first_sample = bi * span - base;
|
||||
const u64 last_sample = std::min((bi + 1) * span, base + n) - 1 - base;
|
||||
// x is in data coordinates: x0() is the retained window's first sample, so
|
||||
// window index j plots at (base + j) * dx.
|
||||
PLOT_CHECK_NEAR(out[4 * k], f32(f64(base + first_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[4 * k + 1], want[0], 1e-6);
|
||||
PLOT_CHECK_NEAR(out[4 * k + 2], f32(f64(base + last_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[4 * k + 3], want[1], 1e-6);
|
||||
const plot::usize lo = static_cast<plot::usize>(2 * (lead + 2 * j));
|
||||
const plot::usize hi = lo + 2;
|
||||
PLOT_CHECK_NEAR(out[lo], f32(f64(base + first_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[lo + 1], want[0], 1e-6);
|
||||
PLOT_CHECK_NEAR(out[hi], f32(f64(base + last_sample) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[hi + 1], want[1], 1e-6);
|
||||
}
|
||||
for (u64 i = 0; i < tail; ++i) { // the partial trailing bucket: the newest samples
|
||||
const plot::usize k = static_cast<plot::usize>(2 * (lead + 2 * env + i));
|
||||
PLOT_CHECK_NEAR(out[k], f32(f64(base + trail + i) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[k + 1], value(base + trail + i), 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,3 +361,62 @@ PLOT_TEST(stream_series_pyramid_memory_is_bounded_by_the_window) {
|
||||
// differ by a bucket each, hence the slack.
|
||||
PLOT_CHECK(entries <= entries_after_10m + kBucket);
|
||||
}
|
||||
|
||||
PLOT_TEST(gather_at_a_lod_level_reaches_both_ends_of_the_window) {
|
||||
// The pyramid stores complete buckets only, so its envelope stops at bucket
|
||||
// boundaries. Without the two partial edge buckets drawn from raw samples the
|
||||
// geometry would stop short of the newest samples -- the ones a live trace is
|
||||
// watched for -- and of the oldest retained ones.
|
||||
constexpr u64 kCapacity = 1u << 13;
|
||||
constexpr u64 kPushes = 40013;
|
||||
constexpr u64 kBase = kPushes - kCapacity; // 31821: not a multiple of kBucket
|
||||
const auto value = [](u64 i) {
|
||||
if (i == kBase) return 25.0f; // the oldest retained sample
|
||||
if (i == kPushes - 1) return -25.0f; // the newest retained sample
|
||||
return f32(i % 97) * 0.25f - 12.0f; // inside [-12, 12], so a bucket's
|
||||
}; // envelope can never equal either spike
|
||||
|
||||
plot::data::StreamSeries s(kCapacity, 0.0, 1e-6);
|
||||
for (u64 i = 0; i < kPushes; ++i) s.push(value(i));
|
||||
s.acquire();
|
||||
const u64 n = s.size();
|
||||
PLOT_CHECK(n == kCapacity);
|
||||
PLOT_CHECK((kBase % kBucket) != 0); // both edges off the grid
|
||||
PLOT_CHECK((kPushes % (u64{kBucket} << 1)) != 0);
|
||||
|
||||
const auto check_ends = [&](u32 level) {
|
||||
std::vector<f32> out;
|
||||
s.gather(level, 0, n, out);
|
||||
PLOT_CHECK(out.size() >= 8u);
|
||||
// Oldest: the first emitted pair is the retained sample at index 0, not the
|
||||
// first whole bucket (which starts `lead` samples later).
|
||||
PLOT_CHECK_NEAR(out[0], f32(s.x0()), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[1], 25.0, 1e-6);
|
||||
// Newest: the last emitted pair is the sample at index n-1, not the last
|
||||
// complete bucket.
|
||||
PLOT_CHECK_NEAR(out[out.size() - 2], f32(s.x0() + f64(n - 1) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[out.size() - 1], -25.0, 1e-6);
|
||||
};
|
||||
PLOT_CHECK(s.pyramid().level_size(1) >= 2);
|
||||
check_ends(1);
|
||||
const u32 dense = s.pyramid().level_for(n, 1024.0);
|
||||
PLOT_CHECK(dense > 0u);
|
||||
check_ends(dense);
|
||||
|
||||
// A static series whose window starts on the grid: only the trailing partial
|
||||
// bucket is at stake, and that is exactly what level_for() picks when a pixel
|
||||
// covers hundreds of samples.
|
||||
std::vector<f32> y(5000);
|
||||
for (u64 i = 0; i < y.size(); ++i) y[i] = value(i);
|
||||
y[0] = 25.0f;
|
||||
y[y.size() - 1] = -25.0f;
|
||||
const u64 ny = static_cast<u64>(y.size());
|
||||
PLOT_CHECK((ny % 256u) != 0u); // 5000 = 19 * 256 + 136 at level 2
|
||||
plot::data::ArraySeries a(y, 0.0, 1e-6);
|
||||
std::vector<f32> out;
|
||||
a.gather(2, 0, ny, out);
|
||||
PLOT_CHECK(out.size() >= 8u);
|
||||
PLOT_CHECK_NEAR(out[0], 0.0, 1e-9); // level 2 starts on the grid
|
||||
PLOT_CHECK_NEAR(out[out.size() - 2], f32(f64(ny - 1) * 1e-6), 1e-9);
|
||||
PLOT_CHECK_NEAR(out[out.size() - 1], -25.0, 1e-6);
|
||||
}
|
||||
Reference in new issue
Block a user