fix(data): level-per-emission pyramid cover, relaxed-atomic ring slots

cover_buckets chose one level per pass, so an odd first whole bucket (half of all

measurement ranges) never reached a level above 0 and folded every remaining

bucket at level 0 -- 15624 entries for a 1e6-sample pyramid. Pick the largest

aligned, in-range, materialised level at each cursor instead; the new cost test

counts folded entries through a PLOT_PYRAMID_COUNT_COVERS-only hook (absent from

normal builds) and fails on the old body.

Ring slots are now relaxed std::atomic: overwrite-on-full means the producer

reuses the slot of a snapshot's oldest sample while the consumer reads it, which

was a data race, not just a stale value. copy_from loads explicitly instead of

std::copy; push stays lock-free. A new producer/consumer case is the one TSan

flags without this.

Also extends the wrapped-window test with x0/bounds/pyramid/sum/sumsq/gather

checks, and corrects the StreamSeries threading note to the sample-count bound.
This commit is contained in:
Martino Ferrari committed 2026-09-27 21:42:53 +02:00
1 parent 138fa478c3
commit d9909f2115
8 files changed
+190 -38

No files matched your search

+24
View File
@@ -1,4 +1,5 @@
#include "harness.hpp"
#include <plot/data/detail/cover_count.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <random>
@@ -104,3 +105,26 @@ PLOT_TEST(pyramid_clear_and_rebuild) {
PLOT_CHECK(p.size() == 1);
PLOT_CHECK_NEAR(p.reduce(0, 1).max, 7.0, 1e-6);
}
PLOT_TEST(pyramid_cover_cost_is_logarithmic_for_unaligned_starts) {
// Answers cannot show this: a decomposition that walks the range one level-0
// bucket at a time returns exactly the same min/max as the O(log n) one. So
// count the folded aggregates. For this query the bad pass emitted 15624
// entries (the level-0 loop consumed the whole remainder); picking a level per
// emission leaves 19, at most two per level, and the aligned start stays at 7.
MinMaxPyramid p;
p.reserve(1'000'000);
for (u64 i = 0; i < 1'000'000; ++i) p.append(0.0f);
PLOT_CHECK(p.level(0).size() == 1'000'000 / kBucket); // 15625, no partial bucket
auto folds_for = [&](u64 begin, u64 end) {
plot::data::detail::cover_entry_count().store(0, std::memory_order_relaxed);
(void)p.reduce_buckets(begin, end);
return plot::data::detail::cover_entry_count().load(std::memory_order_relaxed);
};
const u64 aligned = folds_for(0, 1'000'000); // first whole bucket 0
const u64 unaligned = folds_for(1, 1'000'000); // first whole bucket 1: odd
PLOT_CHECK(aligned <= 64);
PLOT_CHECK(unaligned < 100);
PLOT_CHECK(unaligned <= u64{4} * aligned); // misalignment costs entries, not a scan
}