diff --git a/include/pixie/split_span.h b/include/pixie/split_span.h new file mode 100644 index 0000000..8a939f2 --- /dev/null +++ b/include/pixie/split_span.h @@ -0,0 +1,74 @@ +#pragma once + +/** + * @file split_span.h + * @brief Allocation-free logical ranges split across at most two spans. + */ + +#include +#include +#include + +namespace pixie { + +/** + * @brief A logical contiguous sequence stored in at most two physical spans. + * + * @details Empty input spans are canonicalized away. Iteration visits only + * non-empty physical spans in logical order. The descriptor owns no elements; + * its spans follow the lifetime and invalidation rules of their backing + * storage. + * + * @tparam T Viewed element type, optionally const-qualified. + */ +template +class SplitSpan { + public: + using span_type = std::span; + using const_iterator = typename std::array::const_iterator; + + /** @brief Construct an empty logical range. */ + constexpr SplitSpan() = default; + + /** @brief Construct a range stored in one physical span. */ + constexpr explicit SplitSpan(span_type segment) + : segments_{segment, {}}, segment_count_(segment.empty() ? 0 : 1) {} + + /** @brief Construct a range stored in up to two physical spans. */ + constexpr SplitSpan(span_type first, span_type second) { + if (first.empty()) { + first = second; + second = {}; + } + segments_ = {first, second}; + segment_count_ = static_cast(!first.empty()) + + static_cast(!second.empty()); + } + + /** @brief Return the total number of logical elements. */ + constexpr std::size_t size() const noexcept { + return segments_[0].size() + segments_[1].size(); + } + + /** @brief Return whether the logical range is empty. */ + constexpr bool empty() const noexcept { return segment_count_ == 0; } + + /** @brief Return the number of non-empty physical segments. */ + constexpr std::size_t segment_count() const noexcept { + return segment_count_; + } + + /** @brief Iterate over the non-empty physical segments in logical order. */ + constexpr const_iterator begin() const noexcept { return segments_.begin(); } + + /** @brief Return the end iterator for the non-empty physical segments. */ + constexpr const_iterator end() const noexcept { + return segments_.begin() + static_cast(segment_count_); + } + + private: + std::array segments_{}; + std::size_t segment_count_ = 0; +}; + +} // namespace pixie diff --git a/include/pixie/storage.h b/include/pixie/storage.h index 4cd03c4..5992f80 100644 --- a/include/pixie/storage.h +++ b/include/pixie/storage.h @@ -9,6 +9,7 @@ */ #include +#include #include #include @@ -21,11 +22,51 @@ namespace pixie { /** * @brief CRTP facade for byte-addressable storage. * + * @details `Impl` must provide `size_bytes_impl()` and the following required + * extension points. + * + * @par Required: `begin_position_impl() const` + * Returns the absolute logical position of the first exposed byte as a + * `position_type`. The result may advance when an implementation evicts bytes, + * but positions of retained bytes do not change. + * + * @par Required: `end_position_impl() const` + * Returns the absolute logical position one past the last exposed byte as a + * `position_type`. It must be at least `begin_position_impl()`, and their + * difference must be representable by `std::size_t` and equal + * `size_bytes_impl()`. + * + * @par Required: `segments_impl(position, count_bytes) const` + * Accepts a `position_type` and a `std::size_t` and returns + * `SplitSpan` containing exactly the bytes in `[position, + * position + count_bytes)`. The physical spans are ordered by logical position + * and their combined size equals `count_bytes`. Before calling this hook, the + * facade verifies without overflow that `position` is in the closed range from + * `begin_position_impl()` through `end_position_impl()` and that `count_bytes + * <= end_position_impl() - position`; an empty range at the end position is + * valid. The returned spans borrow the implementation's backing storage. The + * implementation must document the backing storage's ownership requirements + * and the operations that invalidate its spans. + * + * @par Optional: `segments_impl(position, count_bytes)` + * A mutable implementation may provide this extension point to enable + * writable segment access. It accepts the same parameter types as the const + * overload and returns `SplitSpan`. It has the same range, ordering, + * size, lifetime, and invalidation contract as the const overload, and writes + * through the returned spans modify the corresponding logical bytes. If the + * implementation also provides `prepare_segments_impl(position, count_bytes)`, + * the facade calls that hook before validating the range. The preparation hook + * must make the complete requested range available or throw without changing + * the implementation. + * * @tparam Impl Concrete storage implementation. */ template class StorageBase : public SerializationBase { public: + /** @brief Monotonic logical byte position used to address storage ranges. */ + using position_type = std::uint64_t; + /** * @brief Return the logical exposed storage size in bytes. * @details An owning implementation may reserve or pad more memory; use @@ -39,15 +80,96 @@ class StorageBase : public SerializationBase { /** @brief Check whether the storage is empty. */ bool empty() const { return size_bytes() == 0; } - /** @brief Return a read-only view of all logical exposed bytes. */ - std::span as_bytes() const { return impl().as_bytes_impl(); } + /** @brief Return the first logical byte position currently exposed. */ + position_type begin_position() const { return impl().begin_position_impl(); } + + /** @brief Return the position one past the last logical byte exposed. */ + position_type end_position() const { return impl().end_position_impl(); } + + /** @brief Return whether a complete logical range is currently exposed. */ + bool contains(position_type position, std::size_t count_bytes) const { + const position_type begin = begin_position(); + const position_type end = end_position(); + return position >= begin && position <= end && + count_bytes <= end - position; + } + + /** @brief Return a contiguous read-only view of all logical exposed bytes. */ + std::span as_bytes() const + requires requires(const Impl& value) { value.as_bytes_impl(); } + { + return impl().as_bytes_impl(); + } + + /** + * @brief Return all logical bytes as one or two writable physical spans. + * @details Available only for mutable storage implementations. Mutating or + * resizing the storage may invalidate the returned descriptor. + */ + SplitSpan segments() + requires requires(Impl& value) { + { + value.segments_impl(position_type{}, std::size_t{}) + } -> std::same_as>; + } + { + return segments(begin_position(), size_bytes()); + } + + /** + * @brief Return all logical bytes as one or two physical spans. + * @details Contiguous storage returns one segment. A ring-backed storage can + * return its tail followed by its head without allocation or copying. + */ + SplitSpan segments() const { + return segments(begin_position(), size_bytes()); + } + + /** + * @brief Return a checked writable logical byte range as one or two spans. + * @details An implementation with a preparation hook may make a future range + * available before checking it. Any newly exposed bytes remain the caller's + * responsibility to initialize. + * @param position First logical byte position in the range. + * @param count_bytes Number of logical bytes in the range. + * @throws std::out_of_range if the range cannot be made available. + */ + SplitSpan segments(position_type position, std::size_t count_bytes) + requires requires(Impl& value) { + { + value.segments_impl(position_type{}, std::size_t{}) + } -> std::same_as>; + } + { + if constexpr (requires(Impl& value) { + value.prepare_segments_impl(position_type{}, std::size_t{}); + }) { + impl().prepare_segments_impl(position, count_bytes); + } + validate_range(position, count_bytes); + return impl().segments_impl(position, count_bytes); + } + + /** + * @brief Return a checked logical byte range as one or two physical spans. + * @param position First logical byte position in the range. + * @param count_bytes Number of logical bytes in the range. + * @throws std::out_of_range if the range is outside this storage. + */ + SplitSpan segments(position_type position, + std::size_t count_bytes) const { + validate_range(position, count_bytes); + return impl().segments_impl(position, count_bytes); + } /** * @brief Return a read-only view as 16-bit words. * @throws std::invalid_argument if the data is misaligned or its size is not * divisible by the word size. */ - std::span as_words16() const { + std::span as_words16() const + requires requires(const Impl& value) { value.as_bytes_impl(); } + { return as_words(); } @@ -56,12 +178,20 @@ class StorageBase : public SerializationBase { * @throws std::invalid_argument if the data is misaligned or its size is not * divisible by the word size. */ - std::span as_words64() const { + std::span as_words64() const + requires requires(const Impl& value) { value.as_bytes_impl(); } + { return as_words(); } /** @brief Return a non-owning read-only view of all exposed bytes. */ - auto view() const { return impl().view_impl(0, size_bytes()); } + auto view() const + requires requires(const Impl& value) { + value.view_impl(std::size_t{}, std::size_t{}); + } + { + return impl().view_impl(0, size_bytes()); + } /** * @brief Return a non-owning read-only byte subrange. @@ -69,7 +199,11 @@ class StorageBase : public SerializationBase { * @param count_bytes Number of bytes in the view. * @throws std::out_of_range if the subrange is outside this storage. */ - auto view(std::size_t offset_bytes, std::size_t count_bytes) const { + auto view(std::size_t offset_bytes, std::size_t count_bytes) const + requires requires(const Impl& value) { + value.view_impl(std::size_t{}, std::size_t{}); + } + { return impl().view_impl(offset_bytes, count_bytes); } @@ -78,7 +212,9 @@ class StorageBase : public SerializationBase { */ void serialize_impl(BinaryWriter& writer) const { writer.write_size(size_bytes()); - writer.write_bytes(as_bytes()); + for (const std::span segment : segments()) { + writer.write_bytes(segment); + } } /** @brief Resize mutable storage to hold at least @p size_bits bits. */ @@ -124,6 +260,13 @@ class StorageBase : public SerializationBase { } private: + /** @brief Validate a logical byte range without overflowing. */ + void validate_range(position_type position, std::size_t count_bytes) const { + if (!contains(position, count_bytes)) { + throw std::out_of_range("Storage range is outside the working window"); + } + } + /** @brief Return this facade as its concrete CRTP implementation. */ const Impl& impl() const { return static_cast(*this); } diff --git a/include/pixie/storage/aligned.h b/include/pixie/storage/aligned.h index 06a8da7..f191e24 100644 --- a/include/pixie/storage/aligned.h +++ b/include/pixie/storage/aligned.h @@ -56,6 +56,12 @@ class AlignedStorage : public StorageBase { /** @brief Return the logical number of exposed bytes. */ std::size_t size_bytes_impl() const { return logical_size_bytes_; } + /** @brief Return the first logical byte position. */ + position_type begin_position_impl() const { return 0; } + + /** @brief Return the position one past the final logical byte. */ + position_type end_position_impl() const { return logical_size_bytes_; } + /** @brief Return the logical number of exposed bytes. */ std::size_t logical_size_bytes() const { return logical_size_bytes_; } @@ -81,6 +87,18 @@ class AlignedStorage : public StorageBase { .first(logical_size_bytes_); } + /** @brief Return a checked logical byte range as one physical segment. */ + SplitSpan segments_impl(std::size_t offset_bytes, + std::size_t count_bytes) const { + return SplitSpan(as_bytes_impl().subspan(offset_bytes, count_bytes)); + } + + /** @brief Return a checked logical byte range as one writable segment. */ + SplitSpan segments_impl(std::size_t offset_bytes, + std::size_t count_bytes) { + return SplitSpan(writable_bytes_impl().subspan(offset_bytes, count_bytes)); + } + /** @brief Return a checked read-only byte subrange. */ ReadOnlyStorageView view_impl(std::size_t offset_bytes, std::size_t count_bytes) const { diff --git a/include/pixie/storage/implementations.h b/include/pixie/storage/implementations.h index d438c1a..235b5a7 100644 --- a/include/pixie/storage/implementations.h +++ b/include/pixie/storage/implementations.h @@ -6,7 +6,9 @@ * * - `AlignedStorage`: owning, mutable, 64-byte-aligned storage. * - `ReadOnlyStorageView`: non-owning read-only byte storage. + * - `SlidingWindowStorage`: owning fixed-capacity sliding byte storage. */ #include #include +#include diff --git a/include/pixie/storage/read_only_view.h b/include/pixie/storage/read_only_view.h index 9ac8622..47531f0 100644 --- a/include/pixie/storage/read_only_view.h +++ b/include/pixie/storage/read_only_view.h @@ -22,9 +22,21 @@ class ReadOnlyStorageView : public StorageBase { /** @brief Return the number of viewed bytes. */ std::size_t size_bytes_impl() const { return data_.size(); } + /** @brief Return the first logical byte position. */ + position_type begin_position_impl() const { return 0; } + + /** @brief Return the position one past the final logical byte. */ + position_type end_position_impl() const { return data_.size(); } + /** @brief Return the viewed bytes. */ std::span as_bytes_impl() const { return data_; } + /** @brief Return a checked viewed byte range as one physical segment. */ + SplitSpan segments_impl(std::size_t offset_bytes, + std::size_t count_bytes) const { + return SplitSpan(data_.subspan(offset_bytes, count_bytes)); + } + /** @brief Return a checked read-only byte subrange. */ ReadOnlyStorageView view_impl(std::size_t offset_bytes, std::size_t count_bytes) const { diff --git a/include/pixie/storage/sliding_window.h b/include/pixie/storage/sliding_window.h new file mode 100644 index 0000000..5456804 --- /dev/null +++ b/include/pixie/storage/sliding_window.h @@ -0,0 +1,169 @@ +#pragma once + +/** + * @file sliding_window.h + * @brief Fixed-capacity ring-backed sliding byte storage. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pixie { + +/** + * @brief Owning fixed-capacity storage over a monotonically positioned window. + * + * @details The complete backing ring is allocated during construction and is + * never resized. Extending advances `end_position()` and evicts the oldest + * bytes as necessary without initializing the newly exposed storage. Mutable + * and const access use absolute logical positions and return at most two + * physical spans. Requesting a future mutable range extends the window through + * that range automatically; the caller remains responsible for assigning its + * contents. + * + * Segments remain valid while their complete logical range remains inside the + * working window. Moving or destroying the storage invalidates all segments. + */ +class SlidingWindowStorage : public StorageBase { + public: + /** @brief Construct an empty window with immutable byte capacity. */ + explicit SlidingWindowStorage(std::size_t capacity_bytes) + : capacity_bytes_(validate_capacity(capacity_bytes)), + data_(capacity_bytes_) {} + + SlidingWindowStorage(const SlidingWindowStorage&) = default; + + /** @brief Transfer the fixed backing allocation. */ + SlidingWindowStorage(SlidingWindowStorage&& other) noexcept + : capacity_bytes_(std::exchange(other.capacity_bytes_, 0)), + data_(std::move(other.data_)), + begin_position_(std::exchange(other.begin_position_, 0)), + end_position_(std::exchange(other.end_position_, 0)) {} + + SlidingWindowStorage& operator=(const SlidingWindowStorage&) = delete; + SlidingWindowStorage& operator=(SlidingWindowStorage&&) = delete; + + /** @brief Return the immutable logical ring capacity in bytes. */ + std::size_t capacity_bytes() const { return capacity_bytes_; } + + /** @brief Return the number of bytes currently retained. */ + std::size_t size_bytes_impl() const { + return static_cast(end_position_ - begin_position_); + } + + /** @brief Return the oldest retained logical byte position. */ + position_type begin_position_impl() const { return begin_position_; } + + /** @brief Return the position one past the newest retained byte. */ + position_type end_position_impl() const { return end_position_; } + + /** @brief Return the bytes in the fixed backing allocation. */ + std::size_t allocated_bytes_impl() const { return data_.capacity(); } + + /** + * @brief Advance the logical end by a number of bytes. + * @details Newly exposed bytes are not initialized. Advancing beyond the + * capacity evicts the oldest logical bytes while retaining the fixed backing + * allocation. + * @throws std::length_error if the logical position would overflow or if a + * nonzero extension is requested for a zero-capacity window. + */ + void extend(std::size_t count_bytes) { + if (count_bytes == 0) { + return; + } + if (capacity_bytes_ == 0) { + throw std::length_error("Cannot extend a zero-capacity sliding window"); + } + if (count_bytes > + std::numeric_limits::max() - end_position_) { + throw std::length_error("Sliding-window position overflow"); + } + + const position_type new_end = end_position_ + count_bytes; + begin_position_ = new_end > capacity_bytes_ ? new_end - capacity_bytes_ : 0; + end_position_ = new_end; + } + + /** + * @brief Extend through a requested future writable range when necessary. + * @throws std::out_of_range if the requested range cannot fit in the window. + * @throws std::length_error if the requested logical range overflows. + */ + void prepare_segments_impl(position_type position, std::size_t count_bytes) { + if (count_bytes == 0 || (position <= end_position_ && + count_bytes <= end_position_ - position)) { + return; + } + if (count_bytes > std::numeric_limits::max() - position) { + throw std::length_error("Sliding-window range overflow"); + } + + const position_type requested_end = position + count_bytes; + if (count_bytes > capacity_bytes_ || position < begin_position_) { + throw std::out_of_range( + "Storage range cannot fit inside the working window"); + } + const position_type extension = requested_end - end_position_; + if constexpr (sizeof(position_type) > sizeof(std::size_t)) { + if (extension > std::numeric_limits::max()) { + throw std::length_error("Sliding-window extension is too large"); + } + } + extend(static_cast(extension)); + } + + /** @brief Return a retained logical range as writable physical segments. */ + SplitSpan segments_impl(position_type position, + std::size_t count_bytes) { + return physical_segments(position, count_bytes, + std::span(data_)); + } + + /** @brief Return a retained logical range as read-only physical segments. */ + SplitSpan segments_impl(position_type position, + std::size_t count_bytes) const { + return physical_segments(position, count_bytes, + std::span(data_)); + } + + private: + static std::size_t validate_capacity(std::size_t capacity_bytes) { + if constexpr (sizeof(std::size_t) > sizeof(position_type)) { + if (capacity_bytes > std::numeric_limits::max()) { + throw std::length_error("Sliding-window capacity is too large"); + } + } + return capacity_bytes; + } + + template + SplitSpan physical_segments(position_type position, + std::size_t count_bytes, + std::span data) const { + if (count_bytes == 0) { + return {}; + } + const std::size_t physical_begin = + static_cast(position % capacity_bytes_); + const std::size_t first_size = + std::min(count_bytes, capacity_bytes_ - physical_begin); + return SplitSpan(data.subspan(physical_begin, first_size), + data.first(count_bytes - first_size)); + } + + std::size_t capacity_bytes_; + std::vector data_; + position_type begin_position_ = 0; + position_type end_position_ = 0; +}; + +} // namespace pixie diff --git a/src/benchmarks/serialization_benchmarks.cpp b/src/benchmarks/serialization_benchmarks.cpp index d1f7841..f384ff8 100644 --- a/src/benchmarks/serialization_benchmarks.cpp +++ b/src/benchmarks/serialization_benchmarks.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -251,6 +252,74 @@ void BM_BinaryWriterBytesSpan(benchmark::State& state) { set_throughput(state, payload_bytes); } +template +void BM_BinaryWriterBytesSplitSpanImpl(benchmark::State& state) { + const std::size_t payload_bytes = static_cast(state.range(0)); + const std::vector source = make_payload(payload_bytes); + std::vector destination(payload_bytes); + std::vector staging(kDefaultStagingBytes); + const std::size_t split_position = Split ? payload_bytes / 2 : payload_bytes; + for (auto _ : state) { + (void)_; + pixie::SpanOutputSink sink(as_writable_span(destination)); + pixie::BinaryWriter writer(sink, as_writable_span(staging)); + const pixie::SplitSpan segments( + as_const_span(source).first(split_position), + as_const_span(source).subspan(split_position)); + for (const std::span segment : segments) { + writer.write_bytes(segment); + } + writer.finish(); + benchmark::DoNotOptimize(sink.size_bytes()); + benchmark::ClobberMemory(); + } + set_throughput(state, payload_bytes); +} + +void BM_BinaryWriterBytesSplitSpanOne(benchmark::State& state) { + BM_BinaryWriterBytesSplitSpanImpl(state); +} + +void BM_BinaryWriterBytesSplitSpanTwo(benchmark::State& state) { + BM_BinaryWriterBytesSplitSpanImpl(state); +} + +void BM_SpanDescriptor(benchmark::State& state) { + const std::vector source = make_payload(64); + for (auto _ : state) { + (void)_; + const std::span segment = as_const_span(source); + benchmark::DoNotOptimize(segment.data()); + benchmark::DoNotOptimize(segment.size()); + } +} + +template +void BM_SplitSpanDescriptorImpl(benchmark::State& state) { + const std::vector source = make_payload(64); + constexpr std::size_t kSplitPosition = Split ? 32 : 64; + for (auto _ : state) { + (void)_; + const pixie::SplitSpan segments( + as_const_span(source).first(kSplitPosition), + as_const_span(source).subspan(kSplitPosition)); + std::size_t size = 0; + for (const std::span segment : segments) { + benchmark::DoNotOptimize(segment.data()); + size += segment.size(); + } + benchmark::DoNotOptimize(size); + } +} + +void BM_SplitSpanDescriptorOne(benchmark::State& state) { + BM_SplitSpanDescriptorImpl(state); +} + +void BM_SplitSpanDescriptorTwo(benchmark::State& state) { + BM_SplitSpanDescriptorImpl(state); +} + void BM_BinaryWriterBytesVector(benchmark::State& state) { const std::size_t payload_bytes = static_cast(state.range(0)); const std::vector source = make_payload(payload_bytes); @@ -544,12 +613,36 @@ BENCHMARK(BM_BinaryReaderU64) ->PIXIE_SERIALIZATION_TIMING(); BENCHMARK(BM_BinaryWriterBytesSpan) + ->Arg(1) + ->Arg(2) + ->Arg(64) ->Arg(4 * kKiB) ->Arg(1 * kMiB) ->Arg(64 * kMiB) ->ArgName("payload_bytes") ->PIXIE_SERIALIZATION_TIMING(); +BENCHMARK(BM_BinaryWriterBytesSplitSpanOne) + ->Arg(1) + ->Arg(2) + ->Arg(64) + ->Arg(4 * kKiB) + ->Arg(1 * kMiB) + ->ArgName("payload_bytes") + ->PIXIE_SERIALIZATION_TIMING(); + +BENCHMARK(BM_BinaryWriterBytesSplitSpanTwo) + ->Arg(2) + ->Arg(64) + ->Arg(4 * kKiB) + ->Arg(1 * kMiB) + ->ArgName("payload_bytes") + ->PIXIE_SERIALIZATION_TIMING(); + +BENCHMARK(BM_SpanDescriptor)->PIXIE_SERIALIZATION_TIMING(); +BENCHMARK(BM_SplitSpanDescriptorOne)->PIXIE_SERIALIZATION_TIMING(); +BENCHMARK(BM_SplitSpanDescriptorTwo)->PIXIE_SERIALIZATION_TIMING(); + BENCHMARK(BM_BinaryWriterBytesVector) ->Arg(4 * kKiB) ->Arg(1 * kMiB) diff --git a/src/tests/storage_tests.cpp b/src/tests/storage_tests.cpp index 8c29bd7..f644358 100644 --- a/src/tests/storage_tests.cpp +++ b/src/tests/storage_tests.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -40,14 +41,52 @@ concept HasWritableBytes = requires(Storage value) { value.writable_bytes(); }; template concept HasResize = requires(Storage value) { value.resize(1); }; +template +concept HasMutableSegments = requires(Storage& value) { + { value.segments() } -> std::same_as>; +}; + +template +concept HasContiguousBytes = requires(const Storage& value) { + { value.as_bytes() } -> std::same_as>; +}; + +template +std::vector collect_segments( + const pixie::SplitSpan& segments) { + std::vector result; + result.reserve(segments.size()); + for (const std::span segment : segments) { + result.insert(result.end(), segment.begin(), segment.end()); + } + return result; +} + +void assign_segments(pixie::SlidingWindowStorage& storage, + pixie::SlidingWindowStorage::position_type position, + std::span bytes) { + const pixie::SplitSpan destination = + storage.segments(position, bytes.size()); + std::size_t copied = 0; + for (const std::span segment : destination) { + std::ranges::copy(bytes.subspan(copied, segment.size()), segment.begin()); + copied += segment.size(); + } +} + TYPED_TEST(StorageSpecificationTest, EmptyStorageHasConsistentViews) { const std::array bytes{}; auto storage = make_storage(bytes); EXPECT_TRUE(storage.empty()); EXPECT_EQ(storage.size_bytes(), 0u); EXPECT_EQ(storage.size_bits(), 0u); + EXPECT_EQ(storage.begin_position(), 0u); + EXPECT_EQ(storage.end_position(), 0u); + EXPECT_TRUE(storage.contains(0, 0)); EXPECT_TRUE(storage.as_bytes().empty()); EXPECT_TRUE(storage.view().empty()); + EXPECT_TRUE(storage.segments().empty()); + EXPECT_EQ(storage.segments().segment_count(), 0u); } TYPED_TEST(StorageSpecificationTest, SupportsByteAndNestedViews) { @@ -60,7 +99,33 @@ TYPED_TEST(StorageSpecificationTest, SupportsByteAndNestedViews) { EXPECT_EQ(middle.as_bytes()[0], std::byte{2}); EXPECT_EQ(nested.as_bytes()[0], std::byte{3}); EXPECT_EQ(nested.as_bytes()[1], std::byte{4}); + EXPECT_EQ(storage.begin_position(), 0u); + EXPECT_EQ(storage.end_position(), bytes.size()); + EXPECT_TRUE(storage.contains(2, 4)); + EXPECT_FALSE(storage.contains(7, 2)); + const auto segments = storage.segments(2, 4); + ASSERT_EQ(segments.segment_count(), 1u); + EXPECT_EQ(segments.size(), 4u); + EXPECT_TRUE(std::ranges::equal(*segments.begin(), middle.as_bytes())); EXPECT_THROW(storage.view(storage.size_bytes(), 1), std::out_of_range); + EXPECT_THROW(storage.segments(storage.size_bytes(), 1), std::out_of_range); +} + +TEST(SplitSpanTest, CanonicalizesAndIteratesTwoSegmentsInLogicalOrder) { + const std::array first = {1, 2}; + const std::array second = {3, 4, 5}; + const pixie::SplitSpan split(first, second); + + EXPECT_EQ(split.size(), 5u); + EXPECT_EQ(split.segment_count(), 2u); + auto segment = split.begin(); + EXPECT_TRUE(std::ranges::equal(*segment++, first)); + EXPECT_TRUE(std::ranges::equal(*segment++, second)); + EXPECT_EQ(segment, split.end()); + + const pixie::SplitSpan canonical({}, second); + ASSERT_EQ(canonical.segment_count(), 1u); + EXPECT_TRUE(std::ranges::equal(*canonical.begin(), second)); } TYPED_TEST(StorageSpecificationTest, ProvidesAlignedWordViewsWhenValid) { @@ -174,6 +239,24 @@ TEST(AlignedStorageTest, PadsResizesAndProvidesWritableStorage) { storage.shrink_to_fit(); } +TEST(AlignedStorageTest, MutableSegmentsModifyTheBackingStorage) { + static_assert(HasMutableSegments); + static_assert( + std::same_as< + decltype(std::declval().segments()), + pixie::SplitSpan>); + + pixie::AlignedStorage storage(4 * 8); + auto segments = storage.segments(1, 2); + ASSERT_EQ(segments.segment_count(), 1u); + (*segments.begin())[0] = std::byte{42}; + (*segments.begin())[1] = std::byte{43}; + + EXPECT_EQ(storage.as_bytes()[1], std::byte{42}); + EXPECT_EQ(storage.as_bytes()[2], std::byte{43}); + EXPECT_THROW(storage.segments(storage.size_bytes(), 1), std::out_of_range); +} + TEST(AlignedStorageTest, CopiesCompleteWordsIntoAlignedStorage) { std::array words = {1, 2, 3}; const pixie::AlignedStorage storage{std::span(words)}; @@ -189,6 +272,11 @@ TEST(AlignedStorageTest, CopiesCompleteWordsIntoAlignedStorage) { TEST(ReadOnlyStorageViewTest, MutatingOperationsAreNotAvailable) { static_assert(!HasWritableBytes); static_assert(!HasResize); + static_assert(!HasMutableSegments); + static_assert( + std::same_as< + decltype(std::declval().segments()), + pixie::SplitSpan>); } TEST(ReadOnlyStorageViewTest, DeserializeRejectsTruncatedInput) { @@ -203,6 +291,179 @@ TEST(ReadOnlyStorageViewTest, DeserializeRejectsTruncatedInput) { EXPECT_EQ(reader.position(), 0u); } +TEST(SlidingWindowStorageTest, ExtendsAndExposesAbsolutePositions) { + pixie::SlidingWindowStorage storage(8); + const std::array bytes = {std::byte{1}, std::byte{2}, std::byte{3}}; + storage.extend(bytes.size()); + assign_segments(storage, 0, bytes); + + EXPECT_EQ(storage.capacity_bytes(), 8u); + EXPECT_EQ(storage.allocated_bytes(), 8u); + EXPECT_EQ(storage.begin_position(), 0u); + EXPECT_EQ(storage.end_position(), 3u); + EXPECT_EQ(storage.size_bytes(), 3u); + EXPECT_TRUE(storage.contains(0, 3)); + EXPECT_TRUE(storage.contains(3, 0)); + EXPECT_FALSE(storage.contains(3, 1)); + EXPECT_EQ(collect_segments(std::as_const(storage).segments()), + std::vector(bytes.begin(), bytes.end())); +} + +TEST(SlidingWindowStorageTest, WrapsEvictsAndRejectsExpiredRanges) { + pixie::SlidingWindowStorage storage(5); + const std::array first = {std::byte{0}, std::byte{1}, std::byte{2}}; + const std::array second = {std::byte{3}, std::byte{4}, std::byte{5}, + std::byte{6}}; + assign_segments(storage, 0, first); + assign_segments(storage, first.size(), second); + + EXPECT_EQ(storage.begin_position(), 2u); + EXPECT_EQ(storage.end_position(), 7u); + EXPECT_EQ(storage.size_bytes(), 5u); + EXPECT_EQ(storage.segments().segment_count(), 2u); + EXPECT_EQ(collect_segments(std::as_const(storage).segments()), + (std::vector{std::byte{2}, std::byte{3}, std::byte{4}, std::byte{5}, + std::byte{6}})); + EXPECT_THROW(storage.segments(1, 1), std::out_of_range); + EXPECT_THROW(std::as_const(storage).segments(6, 2), std::out_of_range); + EXPECT_NO_THROW(storage.segments(storage.end_position(), 0)); +} + +TEST(SlidingWindowStorageTest, FutureWritableRangeExtendsAutomatically) { + pixie::SlidingWindowStorage storage(5); + const std::array bytes = {std::byte{3}, std::byte{4}, std::byte{5}}; + assign_segments(storage, 2, bytes); + + EXPECT_EQ(storage.begin_position(), 0u); + EXPECT_EQ(storage.end_position(), 5u); + EXPECT_EQ(collect_segments(std::as_const(storage).segments(2, 3)), + std::vector(bytes.begin(), bytes.end())); +} + +TEST(SlidingWindowStorageTest, InvalidFutureRangeDoesNotExtend) { + pixie::SlidingWindowStorage storage(4); + storage.extend(2); + + EXPECT_THROW(storage.segments(2, 5), std::out_of_range); + EXPECT_EQ(storage.begin_position(), 0u); + EXPECT_EQ(storage.end_position(), 2u); +} + +TEST(SlidingWindowStorageTest, OverflowingFutureRangeDoesNotExtend) { + pixie::SlidingWindowStorage storage(4); + constexpr auto max_position = + std::numeric_limits::max(); + + EXPECT_THROW(storage.segments(max_position - 1, 3), std::length_error); + EXPECT_EQ(storage.begin_position(), 0u); + EXPECT_EQ(storage.end_position(), 0u); +} + +TEST(SlidingWindowStorageTest, SupportsMutableRangesAcrossTheWrapPoint) { + pixie::SlidingWindowStorage storage(5); + const std::array bytes = {std::byte{0}, std::byte{1}, std::byte{2}, + std::byte{3}, std::byte{4}, std::byte{5}, + std::byte{6}}; + assign_segments(storage, 2, std::span(bytes).subspan(2)); + + auto segments = storage.segments(4, 3); + ASSERT_EQ(segments.segment_count(), 2u); + std::byte replacement = std::byte{40}; + for (const std::span segment : segments) { + for (std::byte& value : segment) { + value = replacement; + replacement = + static_cast(static_cast(replacement) + 10); + } + } + + EXPECT_EQ(collect_segments(std::as_const(storage).segments()), + (std::vector{std::byte{2}, std::byte{3}, std::byte{40}, + std::byte{50}, std::byte{60}})); +} + +TEST(SlidingWindowStorageTest, RetainedSegmentsKeepTheirAddresses) { + pixie::SlidingWindowStorage storage(6); + const std::array initial = {std::byte{0}, std::byte{1}, std::byte{2}, + std::byte{3}, std::byte{4}, std::byte{5}}; + assign_segments(storage, 0, initial); + const auto retained = std::as_const(storage).segments(1, 2); + const std::byte* const address = retained.begin()->data(); + + const std::array next = {std::byte{6}}; + storage.extend(next.size()); + assign_segments(storage, 6, next); + + ASSERT_TRUE(storage.contains(1, 2)); + EXPECT_EQ(retained.begin()->data(), address); + EXPECT_EQ((*retained.begin())[0], std::byte{1}); + EXPECT_EQ((*retained.begin())[1], std::byte{2}); +} + +TEST(SlidingWindowStorageTest, ExtensionBeyondCapacityRetainsNewestPositions) { + pixie::SlidingWindowStorage storage(4); + const std::array bytes = {std::byte{0}, std::byte{1}, std::byte{2}, + std::byte{3}, std::byte{4}, std::byte{5}}; + storage.extend(bytes.size()); + assign_segments(storage, 2, std::span(bytes).subspan(2)); + + EXPECT_EQ(storage.begin_position(), 2u); + EXPECT_EQ(storage.end_position(), 6u); + EXPECT_EQ( + collect_segments(std::as_const(storage).segments()), + (std::vector{std::byte{2}, std::byte{3}, std::byte{4}, std::byte{5}})); +} + +TEST(SlidingWindowStorageTest, SerializesRetainedBytesInLogicalOrder) { + pixie::SlidingWindowStorage storage(4); + const std::array bytes = {std::byte{0}, std::byte{1}, std::byte{2}, + std::byte{3}, std::byte{4}, std::byte{5}}; + storage.extend(bytes.size()); + assign_segments(storage, 2, std::span(bytes).subspan(2)); + pixie::VectorOutputSink output; + pixie::BinaryWriter writer(output); + storage.serialize(writer); + writer.finish(); + + const std::vector artifact = output.take(); + pixie::BinaryReader reader(artifact); + EXPECT_EQ(reader.read_size(), 4u); + EXPECT_TRUE(std::ranges::equal( + reader.read_bytes(4), + std::array{std::byte{2}, std::byte{3}, std::byte{4}, std::byte{5}})); + EXPECT_TRUE(reader.empty()); +} + +TEST(SlidingWindowStorageTest, HasFixedCapacityAndIndependentCopies) { + static_assert(pixie::StorageImplementation); + static_assert(HasMutableSegments); + static_assert(!HasContiguousBytes); + static_assert(!HasWritableBytes); + static_assert(!HasResize); + static_assert(std::is_copy_constructible_v); + static_assert(std::is_move_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_assignable_v); + + pixie::SlidingWindowStorage original(3); + const std::array bytes = {std::byte{1}, std::byte{2}}; + assign_segments(original, 0, bytes); + pixie::SlidingWindowStorage copy(original); + (*copy.segments(0, 1).begin())[0] = std::byte{9}; + + EXPECT_EQ((*std::as_const(original).segments(0, 1).begin())[0], std::byte{1}); + EXPECT_EQ((*std::as_const(copy).segments(0, 1).begin())[0], std::byte{9}); +} + +TEST(SlidingWindowStorageTest, ZeroCapacityAcceptsOnlyEmptyExtensions) { + pixie::SlidingWindowStorage storage(0); + storage.extend(0); + EXPECT_TRUE(storage.empty()); + EXPECT_TRUE(storage.segments().empty()); + + EXPECT_THROW(storage.extend(1), std::length_error); +} + TEST(MappedFileTest, MapsContentsAndIsMoveOnly) { static_assert(!std::is_copy_constructible_v); const auto path =